From a063090a52f17eedf406ceeb6b5a9a1900c2c851 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 4 Aug 2026 08:59:37 +0300 Subject: [PATCH] fix(web): stop React 19 portal removeChild/insertBefore crashes (#624) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web): replace custom Dialog with Radix to stop portal races Hand-rolled createPortal siblings on document.body raced with Radix Select/DropdownMenu/Sonner under React 19 (insertBefore/removeChild). Use @radix-ui/react-dialog like select/dropdown adapters and bail out overview ResizeObserver setState when values are unchanged. Signed-off-by: Mikhail Neradkov * fix(web): stop portal removeChild races on search and shell Move Radix portals to #skillhub-portals, drop language-switcher body portal (match UserMenu), remove Select/Dropdown exit animations, and keep previous search results while typing to avoid skeleton unmount churn. Signed-off-by: Mikhail Neradkov * fix(web): systemic overlay teardown for React 19 removeChild Render Select/Dropdown in-tree (no Portal), translate=no on overlays, dismiss overlays on pathname change, defer navigate after close, host Toaster in #skillhub-portals, and add recoverable RouteError UI. Signed-off-by: Mikhail Neradkov * fix(web): harden overlay dismiss after code review Clip decorative layer only, dismiss Dialog via overlay pointer events, log RouteError once in useEffect, and defer skill-detail leave navigations. Signed-off-by: Mikhail Neradkov * fix(web): batch starred highlight and defer Sonner toasts Avoid N× per-card star fetches on /search and flushSync toast races during React 19 list re-renders (removeChild / insertBefore). Signed-off-by: Mikhail Neradkov * fix(web): block Chrome Translate DOM races on skill overview Keep html/#root notranslate, sync lang with i18n, and harden overview expand so memoized markdown + quiet ResizeObserver avoid insertBefore crashes. Signed-off-by: Mikhail Neradkov --------- Signed-off-by: Mikhail Neradkov --- web/index.html | 8 +- web/package.json | 1 + web/pnpm-lock.yaml | 302 ++++++++++++++++++ web/src/app/layout.tsx | 40 ++- web/src/app/router.tsx | 3 + .../features/skill/markdown-renderer.test.tsx | 20 ++ web/src/features/skill/markdown-renderer.tsx | 9 +- .../features/skill/overview-collapse.test.ts | 6 + web/src/features/skill/overview-collapse.ts | 5 + web/src/features/skill/skill-card.tsx | 7 +- web/src/features/social/use-star.test.ts | 5 + web/src/features/social/use-star.ts | 22 +- web/src/i18n/locales/en.json | 5 + web/src/i18n/locales/zh.json | 5 + web/src/pages/dashboard/publish.tsx | 6 +- web/src/pages/skill-detail.tsx | 67 ++-- web/src/shared/components/confirm-dialog.tsx | 10 +- .../components/language-switcher.test.ts | 9 +- .../shared/components/language-switcher.tsx | 100 ++++-- web/src/shared/components/route-error.test.ts | 8 + web/src/shared/components/route-error.tsx | 29 ++ web/src/shared/components/toaster.tsx | 56 ++-- web/src/shared/hooks/use-skill-queries.ts | 5 +- .../shared/lib/dismiss-open-overlays.test.ts | 37 +++ web/src/shared/lib/dismiss-open-overlays.ts | 55 ++++ web/src/shared/lib/document-language.test.ts | 28 ++ web/src/shared/lib/document-language.ts | 26 ++ .../lib/navigate-after-overlays.test.ts | 28 ++ web/src/shared/lib/navigate-after-overlays.ts | 21 ++ web/src/shared/lib/portal-container.test.ts | 21 ++ web/src/shared/lib/portal-container.ts | 16 + web/src/shared/lib/toast.ts | 33 +- web/src/shared/ui/dialog.test.ts | 34 -- web/src/shared/ui/dialog.test.tsx | 81 +++++ web/src/shared/ui/dialog.tsx | 228 +++++-------- web/src/shared/ui/dropdown-menu.tsx | 26 +- web/src/shared/ui/select.test.ts | 8 + web/src/shared/ui/select.tsx | 50 +-- 38 files changed, 1084 insertions(+), 336 deletions(-) create mode 100644 web/src/shared/components/route-error.test.ts create mode 100644 web/src/shared/components/route-error.tsx create mode 100644 web/src/shared/lib/dismiss-open-overlays.test.ts create mode 100644 web/src/shared/lib/dismiss-open-overlays.ts create mode 100644 web/src/shared/lib/document-language.test.ts create mode 100644 web/src/shared/lib/document-language.ts create mode 100644 web/src/shared/lib/navigate-after-overlays.test.ts create mode 100644 web/src/shared/lib/navigate-after-overlays.ts create mode 100644 web/src/shared/lib/portal-container.test.ts create mode 100644 web/src/shared/lib/portal-container.ts delete mode 100644 web/src/shared/ui/dialog.test.ts create mode 100644 web/src/shared/ui/dialog.test.tsx diff --git a/web/index.html b/web/index.html index 99024a11..63a5423d 100644 --- a/web/index.html +++ b/web/index.html @@ -1,8 +1,9 @@ - + + -
+ +
+ +
diff --git a/web/package.json b/web/package.json index a39046a8..04bee520 100644 --- a/web/package.json +++ b/web/package.json @@ -36,6 +36,7 @@ "generate-api": "openapi-typescript http://localhost:8080/v3/api-docs -o src/api/generated/schema.d.ts" }, "dependencies": { + "@radix-ui/react-dialog": "^1.1.23", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-select": "^2.2.6", "@tanstack/react-query": "^5.64.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 8ebbf05f..b27962aa 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -21,6 +21,9 @@ importers: .: dependencies: + '@radix-ui/react-dialog': + specifier: ^1.1.23 + version: 1.1.23(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-dropdown-menu': specifier: ^2.1.16 version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -638,6 +641,9 @@ packages: '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + '@radix-ui/primitive@1.1.7': + resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} + '@radix-ui/react-arrow@1.1.7': resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} peerDependencies: @@ -673,6 +679,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-compose-refs@1.1.5': + resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-context@1.1.2': resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} peerDependencies: @@ -682,6 +697,28 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.2.2': + resolution: {integrity: sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.23': + resolution: {integrity: sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-direction@1.1.1': resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} peerDependencies: @@ -704,6 +741,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dismissable-layer@1.1.19': + resolution: {integrity: sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-dropdown-menu@2.1.16': resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} peerDependencies: @@ -726,6 +776,28 @@ packages: '@types/react': optional: true + '@radix-ui/react-focus-guards@1.1.6': + resolution: {integrity: sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.16': + resolution: {integrity: sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-focus-scope@1.1.7': resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} peerDependencies: @@ -748,6 +820,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-id@1.1.4': + resolution: {integrity: sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-menu@2.1.16': resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} peerDependencies: @@ -774,6 +855,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-portal@1.1.17': + resolution: {integrity: sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-portal@1.1.9': resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} peerDependencies: @@ -787,6 +881,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-presence@1.1.10': + resolution: {integrity: sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-presence@1.1.5': resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} peerDependencies: @@ -800,6 +907,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.1.10': + resolution: {integrity: sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-primitive@2.1.3': resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} peerDependencies: @@ -848,6 +968,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-slot@1.3.3': + resolution: {integrity: sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-callback-ref@1.1.1': resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} peerDependencies: @@ -857,6 +986,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-callback-ref@1.1.4': + resolution: {integrity: sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-controllable-state@1.2.2': resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} peerDependencies: @@ -866,6 +1004,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-controllable-state@1.2.6': + resolution: {integrity: sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-effect-event@0.0.2': resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} peerDependencies: @@ -875,6 +1022,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-effect-event@0.0.5': + resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-escape-keydown@1.1.1': resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} peerDependencies: @@ -893,6 +1049,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-layout-effect@1.1.4': + resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-previous@1.1.1': resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} peerDependencies: @@ -3386,6 +3551,8 @@ snapshots: '@radix-ui/primitive@1.1.3': {} + '@radix-ui/primitive@1.1.7': {} + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -3413,12 +3580,47 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.4)': dependencies: react: 19.2.4 optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-context@1.2.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dialog@1.1.23(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.4)': dependencies: react: 19.2.4 @@ -3438,6 +3640,19 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-dismissable-layer@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -3459,6 +3674,23 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-focus-guards@1.1.6(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-focus-scope@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) @@ -3477,6 +3709,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-id@1.1.4(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -3521,6 +3760,16 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-portal@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -3531,6 +3780,15 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-presence@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) @@ -3541,6 +3799,15 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-primitive@2.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) @@ -3603,12 +3870,25 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-slot@1.3.3(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.4)': dependencies: react: 19.2.4 optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-use-callback-ref@1.1.4(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.4)': dependencies: '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) @@ -3617,6 +3897,15 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.4)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) @@ -3624,6 +3913,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.4)': dependencies: '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) @@ -3637,6 +3933,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.4)': dependencies: react: 19.2.4 diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 4b48b25e..dc5a3b80 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,10 +1,12 @@ -import { Suspense, useEffect, useState } from 'react' +import { Suspense, useEffect, useRef, useState } from 'react' import { Outlet, Link, useRouterState } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { useAuth } from '@/features/auth/use-auth' import { LanguageSwitcher } from '@/shared/components/language-switcher' import { UserMenu } from '@/shared/components/user-menu' import { NotificationBell } from '@/features/notification/notification-bell' +import { dismissOpenOverlays } from '@/shared/lib/dismiss-open-overlays' +import { syncDocumentLanguage } from '@/shared/lib/document-language' import { getAppHeaderClassName } from './layout-header-style' import { getAppMainContentLayout, resolveAppMainContentPathname } from './layout-main-content' @@ -15,7 +17,7 @@ import { getAppMainContentLayout, resolveAppMainContentPathname } from './layout * fallback used while lazy route modules are loading. */ export function Layout() { - const { t } = useTranslation() + const { t, i18n } = useTranslation() const { pathname, resolvedPathname } = useRouterState({ select: (s) => ({ pathname: s.location.pathname, @@ -24,9 +26,14 @@ export function Layout() { }) const { user, isLoading } = useAuth() const [isHeaderElevated, setIsHeaderElevated] = useState(false) + const previousPathnameRef = useRef(pathname) const contentLayoutPathname = resolveAppMainContentPathname(pathname, resolvedPathname) const mainContentLayout = getAppMainContentLayout(contentLayoutPathname) + useEffect(() => { + syncDocumentLanguage(i18n.resolvedLanguage ?? i18n.language) + }, [i18n.language, i18n.resolvedLanguage]) + useEffect(() => { const updateHeaderElevation = () => { setIsHeaderElevated(window.scrollY > 0) @@ -40,6 +47,15 @@ export function Layout() { } }, []) + // Pathname-only: search debounce on /search must not dismiss overlays mid-typing. + useEffect(() => { + if (previousPathnameRef.current === pathname) { + return + } + previousPathnameRef.current = pathname + dismissOpenOverlays() + }, [pathname]) + const navItems: Array<{ label: string to: string @@ -60,15 +76,17 @@ export function Layout() { } return ( -
- {/* Decorative gradient orb */} -
+
+ {/* Clip only the decorative layer so in-tree Select/Dropdown are not cropped. */} +
+
+
{/* Header */}
diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index ec9749a5..4280e4ca 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -3,6 +3,7 @@ import { createRouter, createRoute, createRootRoute, redirect } from '@tanstack/ import { Layout } from './layout' import { getCurrentUser } from '@/api/client' import { RoleGuard } from '@/shared/components/role-guard' +import { RouteError } from '@/shared/components/route-error' import { createRequireAuth } from '@/shared/lib/auth-route' import { clearDynamicImportReloadGuard, recoverFromDynamicImportError } from '@/shared/lib/dynamic-import-recovery' import { normalizeSearchQuery } from '@/shared/lib/search-query' @@ -157,6 +158,7 @@ function DefaultNotFound() { const rootRoute = createRootRoute({ component: Layout, notFoundComponent: DefaultNotFound, + errorComponent: RouteError, }) const requireAuth = createRequireAuth(getCurrentUser) @@ -484,6 +486,7 @@ const routeTree = rootRoute.addChildren([ export const router = createRouter({ routeTree, defaultNotFoundComponent: DefaultNotFound, + defaultErrorComponent: RouteError, }) declare module '@tanstack/react-router' { diff --git a/web/src/features/skill/markdown-renderer.test.tsx b/web/src/features/skill/markdown-renderer.test.tsx index 20fe3413..d7ac6366 100644 --- a/web/src/features/skill/markdown-renderer.test.tsx +++ b/web/src/features/skill/markdown-renderer.test.tsx @@ -32,4 +32,24 @@ describe('MarkdownRenderer links', () => { expect(screen.getByRole('link', { name: 'Usage' }).getAttribute('href')).toBe('docs/usage.md') }) + + it('marks the document container as notranslate', () => { + const { container } = render() + const root = container.firstElementChild as HTMLElement + + expect(root.getAttribute('translate')).toBe('no') + expect(root.classList.contains('notranslate')).toBe(true) + }) + + it('keeps the same content root when re-rendered with a stable onLinkClick', () => { + const onLinkClick = vi.fn() + const { container, rerender } = render( + , + ) + const firstRoot = container.firstElementChild + + rerender() + + expect(container.firstElementChild).toBe(firstRoot) + }) }) diff --git a/web/src/features/skill/markdown-renderer.tsx b/web/src/features/skill/markdown-renderer.tsx index 8bb0c198..f90ae9d3 100644 --- a/web/src/features/skill/markdown-renderer.tsx +++ b/web/src/features/skill/markdown-renderer.tsx @@ -1,4 +1,4 @@ -import { useMemo, type MouseEvent } from 'react' +import { memo, useMemo, type MouseEvent } from 'react' import ReactMarkdown from 'react-markdown' import rehypeHighlight from 'rehype-highlight' import rehypeSanitize from 'rehype-sanitize' @@ -21,7 +21,7 @@ interface MarkdownRendererProps { * dedicated UI sections and should not appear twice in the document body. * Memoized to prevent re-parsing on every render. */ -export function MarkdownRenderer({ content, className, onLinkClick }: MarkdownRendererProps) { +function MarkdownRendererComponent({ content, className, onLinkClick }: MarkdownRendererProps) { const containerClassName = [ className, 'max-w-none break-words text-sm text-foreground/90 [overflow-wrap:anywhere]', @@ -36,7 +36,7 @@ export function MarkdownRenderer({ content, className, onLinkClick }: MarkdownRe ) return ( -
+
) } + +/** Memoized so collapse/expand parent state does not re-parse large skill docs. */ +export const MarkdownRenderer = memo(MarkdownRendererComponent) diff --git a/web/src/features/skill/overview-collapse.test.ts b/web/src/features/skill/overview-collapse.test.ts index e05451b6..2c75a906 100644 --- a/web/src/features/skill/overview-collapse.test.ts +++ b/web/src/features/skill/overview-collapse.test.ts @@ -4,6 +4,7 @@ import { OVERVIEW_COLLAPSE_MOBILE_VIEWPORT_RATIO, getOverviewCollapseMaxHeight, shouldCollapseOverview, + shouldReleaseOverviewLayoutQuiet, } from './overview-collapse' describe('overview collapse helpers', () => { @@ -21,4 +22,9 @@ describe('overview collapse helpers', () => { expect(shouldCollapseOverview(maxHeight, 1280, 900)).toBe(false) expect(shouldCollapseOverview(maxHeight + 1, 1280, 900)).toBe(true) }) + + it('releases overview layout quiet only for the latest toggle generation', () => { + expect(shouldReleaseOverviewLayoutQuiet(2, 2)).toBe(true) + expect(shouldReleaseOverviewLayoutQuiet(3, 2)).toBe(false) + }) }) diff --git a/web/src/features/skill/overview-collapse.ts b/web/src/features/skill/overview-collapse.ts index 6011ae9b..52a3b725 100644 --- a/web/src/features/skill/overview-collapse.ts +++ b/web/src/features/skill/overview-collapse.ts @@ -13,3 +13,8 @@ export function getOverviewCollapseMaxHeight(viewportWidth: number, viewportHeig export function shouldCollapseOverview(contentHeight: number, viewportWidth: number, viewportHeight: number) { return contentHeight > getOverviewCollapseMaxHeight(viewportWidth, viewportHeight) } + +/** True when a quiet-window rAF may clear the ResizeObserver mute flag. */ +export function shouldReleaseOverviewLayoutQuiet(activeGeneration: number, scheduledGeneration: number) { + return activeGeneration === scheduledGeneration +} diff --git a/web/src/features/skill/skill-card.tsx b/web/src/features/skill/skill-card.tsx index ff8fcd1b..51dae46f 100644 --- a/web/src/features/skill/skill-card.tsx +++ b/web/src/features/skill/skill-card.tsx @@ -1,6 +1,6 @@ import type { SkillSummary } from '@/api/types' import { useAuth } from '@/features/auth/use-auth' -import { useStar } from '@/features/social/use-star' +import { useStarredIdSet } from '@/features/social/use-star' import { Card } from '@/shared/ui/card' import { NamespaceBadge } from '@/shared/components/namespace-badge' import { getHeadlineVersion } from '@/shared/lib/skill-lifecycle' @@ -18,8 +18,9 @@ interface SkillCardProps { */ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCardProps) { const { isAuthenticated } = useAuth() - const { data: starStatus } = useStar(skill.id, highlightStarred && isAuthenticated) - const showStarredHighlight = highlightStarred && isAuthenticated && starStatus?.starred + // Batch highlight via shared ['skills','stars'] — never N× useStar per grid row. + const { starredIds } = useStarredIdSet(highlightStarred && isAuthenticated) + const showStarredHighlight = highlightStarred && isAuthenticated && starredIds.has(skill.id) const headlineVersion = getHeadlineVersion(skill) const isInteractive = typeof onClick === 'function' diff --git a/web/src/features/social/use-star.test.ts b/web/src/features/social/use-star.test.ts index ae6e9d01..0fb469ac 100644 --- a/web/src/features/social/use-star.test.ts +++ b/web/src/features/social/use-star.test.ts @@ -15,6 +15,11 @@ describe('use-star module exports', () => { expect(typeof mod.useStar).toBe('function') }) + it('exports useStarredIdSet as a function', () => { + expect(mod.useStarredIdSet).toBeDefined() + expect(typeof mod.useStarredIdSet).toBe('function') + }) + it('exports useToggleStar as a function', () => { expect(mod.useToggleStar).toBeDefined() expect(typeof mod.useToggleStar).toBe('function') diff --git a/web/src/features/social/use-star.ts b/web/src/features/social/use-star.ts index 97f5470e..a3f68c23 100644 --- a/web/src/features/social/use-star.ts +++ b/web/src/features/social/use-star.ts @@ -1,5 +1,7 @@ +import { useMemo } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { ApiError, fetchJson, getCsrfHeaders, WEB_API_PREFIX } from '@/api/client' +import { useMyStars } from '@/shared/hooks/use-user-queries' interface StarStatus { starred: boolean @@ -15,7 +17,7 @@ async function getStarStatus(skillId: number): Promise { const starred = await fetchJson(`${WEB_API_PREFIX}/skills/${skillId}/star`) return { starred } } catch (error) { - if (error instanceof ApiError && error.status === 401) { + if (error instanceof ApiError && (error.status === 401 || error.status === 403 || error.status === 404)) { return { starred: false } } throw error @@ -41,9 +43,27 @@ export function useStar(skillId: number, enabled = true) { queryKey: ['skills', skillId, 'star'], queryFn: () => getStarStatus(skillId), enabled: !!skillId && enabled, + // List cards / shells must not toast+flushSync on transient star failures. + meta: { skipGlobalErrorHandler: true }, }) } +/** + * Shared starred-id set for list highlight (search/home/landing). + * + * One `['skills', 'stars']` query is reused across every SkillCard mount instead of + * N× per-skill `/star` requests that re-render the grid as each response arrives. + */ +export function useStarredIdSet(enabled = true) { + const query = useMyStars(enabled) + const starredIds = useMemo( + () => new Set((query.data ?? []).map((skill) => skill.id)), + [query.data], + ) + + return { ...query, starredIds } +} + export function useToggleStar(skillId: number) { const queryClient = useQueryClient() diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 2e7d4f28..44a66da4 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1523,6 +1523,11 @@ } } }, + "routeError": { + "title": "Something went wrong", + "description": "The page hit an unexpected error. Reset to continue. If this keeps happening in a private window without extensions, please report it.", + "reset": "Reset" + }, "notification": { "title": "Notifications", "empty": "No notifications", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 2281121a..a707ad3d 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1524,6 +1524,11 @@ } } }, + "routeError": { + "title": "出现错误", + "description": "页面遇到意外错误。请重置后继续。若在无扩展的隐私窗口中仍反复出现,请反馈问题。", + "reset": "重置" + }, "notification": { "title": "通知", "empty": "暂无通知", diff --git a/web/src/pages/dashboard/publish.tsx b/web/src/pages/dashboard/publish.tsx index aecdb02c..bcaeb16a 100644 --- a/web/src/pages/dashboard/publish.tsx +++ b/web/src/pages/dashboard/publish.tsx @@ -25,6 +25,7 @@ import { usePublishSkill } from '@/shared/hooks/use-skill-queries' import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' import { ConfirmDialog } from '@/shared/components/confirm-dialog' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' +import { navigateAfterOverlays } from '@/shared/lib/navigate-after-overlays' import { toast } from '@/shared/lib/toast' import { ApiError } from '@/api/client' @@ -92,7 +93,9 @@ export function PublishPage() { t('publish.pendingReviewDescription', { skill: skillLabel }) ) } - navigate({ to: '/dashboard/skills' }) + navigateAfterOverlays(() => { + navigate({ to: '/dashboard/skills' }) + }) } catch (error) { if (error instanceof ApiError && error.status === 408) { toast.error(t('publish.timeoutTitle'), t('publish.timeoutDescription')) @@ -197,7 +200,6 @@ export function PublishPage() {
diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index b0c6411c..e032834f 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type MouseEvent } from 'react' +import { startTransition, useCallback, useEffect, useRef, useState, type MouseEvent } from 'react' import { useTranslation } from 'react-i18next' import { Link, useParams, useNavigate, useRouterState, useSearch } from '@tanstack/react-router' import { useMutation, useQueryClient } from '@tanstack/react-query' @@ -16,6 +16,7 @@ import { getOverviewCollapseMaxHeight, OVERVIEW_COLLAPSE_DESKTOP_MAX_HEIGHT, shouldCollapseOverview, + shouldReleaseOverviewLayoutQuiet, } from '@/features/skill/overview-collapse' import { resolveSkillActionErrorTitle } from '@/features/skill/skill-action-error' import { isPrecheckConfirmationMessage, extractPrecheckWarnings } from '@/features/publish/publish-error-utils' @@ -34,6 +35,7 @@ import { getSkillLabelSearch, getSkillSquareSearch, normalizeSkillDetailReturnTo import { formatCompactCount } from '@/shared/lib/number-format' import { resolveDocumentationFilePath } from '@/shared/lib/skill-documentation' import { getHeadlineVersion, getOwnerPreviewVersion, getPublishedVersion } from '@/shared/lib/skill-lifecycle' +import { navigateAfterOverlays } from '@/shared/lib/navigate-after-overlays' import { NamespaceBadge } from '@/shared/components/namespace-badge' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/shared/ui/tabs' import { Button } from '@/shared/ui/button' @@ -149,6 +151,8 @@ export function SkillDetailPage() { const [fileBrowserOpen, setFileBrowserOpen] = useState(true) const overviewContentRef = useRef(null) const overviewSectionRef = useRef(null) + const overviewLayoutQuietRef = useRef(false) + const overviewQuietGenerationRef = useRef(0) const { namespace, slug } = useParams({ from: '/space/$namespace/$slug' }) const { user, hasRole } = useAuth() const detailQueriesEnabled = isSkillDetailQueriesEnabled(skillDeleted) @@ -205,7 +209,7 @@ export function SkillDetailPage() { } const updateOverviewState = () => { - if (!overviewContentRef.current) { + if (!overviewContentRef.current || overviewLayoutQuietRef.current) { return } @@ -216,11 +220,13 @@ export function SkillDetailPage() { window.innerHeight, ) - setOverviewMaxHeight(nextMaxHeight) - setIsOverviewCollapsible(nextCollapsible) + // Bail out when ResizeObserver/layout noise repeats the same values to avoid + // re-render storms that race with body portals (Select, DropdownMenu, Dialog). + setOverviewMaxHeight((current) => (current === nextMaxHeight ? current : nextMaxHeight)) + setIsOverviewCollapsible((current) => (current === nextCollapsible ? current : nextCollapsible)) if (!nextCollapsible) { - setIsOverviewExpanded(false) + setIsOverviewExpanded((current) => (current ? false : current)) } } @@ -238,19 +244,32 @@ export function SkillDetailPage() { return () => { window.removeEventListener('resize', updateOverviewState) resizeObserver?.disconnect() + overviewQuietGenerationRef.current += 1 + overviewLayoutQuietRef.current = false } }, [readme]) const handleToggleOverview = () => { - if (!isOverviewExpanded) { - setIsOverviewExpanded(true) - return - } - - setIsOverviewExpanded(false) - requestAnimationFrame(() => { - overviewSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + // Quiet ResizeObserver for the expand/collapse commit (no max-height transition). + const quietGeneration = overviewQuietGenerationRef.current + 1 + overviewQuietGenerationRef.current = quietGeneration + overviewLayoutQuietRef.current = true + const expanding = !isOverviewExpanded + startTransition(() => { + setIsOverviewExpanded(expanding) }) + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (shouldReleaseOverviewLayoutQuiet(overviewQuietGenerationRef.current, quietGeneration)) { + overviewLayoutQuietRef.current = false + } + }) + }) + if (!expanding) { + requestAnimationFrame(() => { + overviewSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }) + } } const refreshSkill = () => { @@ -300,7 +319,7 @@ export function SkillDetailPage() { setPreviewDialogOpen(true) } - const handlePackageMarkdownLinkClick = ( + const handlePackageMarkdownLinkClick = useCallback(( href: string, event: MouseEvent, currentFilePath: string | null | undefined, @@ -320,15 +339,15 @@ export function SkillDetailPage() { } toast.error(t('skillDetail.packageLinkMissingTitle'), t('skillDetail.packageLinkMissingDescription')) - } + }, [files, t]) - const handleOverviewLinkClick = (href: string, event: MouseEvent) => { + const handleOverviewLinkClick = useCallback((href: string, event: MouseEvent) => { handlePackageMarkdownLinkClick(href, event, documentationPath) - } + }, [documentationPath, handlePackageMarkdownLinkClick]) - const handlePreviewLinkClick = (href: string, event: MouseEvent) => { + const handlePreviewLinkClick = useCallback((href: string, event: MouseEvent) => { handlePackageMarkdownLinkClick(href, event, previewNode?.path) - } + }, [handlePackageMarkdownLinkClick, previewNode?.path]) // Download a single file from the skill version const handleDownloadFile = () => { @@ -547,7 +566,9 @@ export function SkillDetailPage() { t('skillDetail.deleteSkillSuccessDescription', { skill: skill.displayName }), ) setDeleteSkillInputOpen(false) - navigate({ to: resolveDeletedSkillReturnTo(search.returnTo) }) + navigateAfterOverlays(() => { + navigate({ to: resolveDeletedSkillReturnTo(search.returnTo) }) + }) queryClient.removeQueries({ queryKey: ['skills', namespace, slug] }) queryClient.invalidateQueries({ queryKey: ['skills', 'my'] }) } catch (error) { @@ -584,7 +605,9 @@ export function SkillDetailPage() { t('skillDetail.withdrawReviewSuccessDescription', { version: withdrawVersionTarget }), ) setWithdrawVersionTarget(null) - navigate({ to: '/dashboard/skills' }) + navigateAfterOverlays(() => { + navigate({ to: '/dashboard/skills' }) + }) } catch (error) { toast.error(t('skillDetail.withdrawReviewErrorTitle'), error instanceof Error ? error.message : '') throw error @@ -886,7 +909,7 @@ export function SkillDetailPage() {
{ await onConfirm() - onOpenChange(false) + // Skip close if the caller already closed (e.g. publish success + navigate). + if (openRef.current) { + onOpenChange(false) + } } return ( diff --git a/web/src/shared/components/language-switcher.test.ts b/web/src/shared/components/language-switcher.test.ts index abdb2380..6408939c 100644 --- a/web/src/shared/components/language-switcher.test.ts +++ b/web/src/shared/components/language-switcher.test.ts @@ -2,13 +2,8 @@ import { describe, expect, it } from 'vitest' import * as mod from './language-switcher' /** - * LanguageSwitcher is a React component that renders a dropdown to switch - * between Chinese and English using i18next. - * All logic depends on i18next hooks and Radix DropdownMenu primitives. - * There are no exported pure helpers or constants to test here. - * - * We verify the module shape so downstream consumers break fast - * if the export contract changes. + * LanguageSwitcher renders an in-tree locale menu (no Radix portal) driven by i18next. + * There are no exported pure helpers; verify the export contract only. */ describe('language-switcher module exports', () => { it('exports the LanguageSwitcher component', () => { diff --git a/web/src/shared/components/language-switcher.tsx b/web/src/shared/components/language-switcher.tsx index 6a7d1e39..d28f54e5 100644 --- a/web/src/shared/components/language-switcher.tsx +++ b/web/src/shared/components/language-switcher.tsx @@ -1,20 +1,25 @@ +import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Button } from '@/shared/ui/button' import { cn } from '@/shared/lib/utils' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/shared/ui/dropdown-menu' import { ChevronDown, Globe } from 'lucide-react' interface LanguageSwitcherProps { className?: string } +/** + * Locale picker for the app shell. + * + * Intentionally avoids Radix DropdownMenu portals: the switcher lives in the + * global header and re-renders with every search/navigation update. Body portals + * from that path race React 19 reconciliation (removeChild / insertBefore). + * Pattern matches UserMenu (absolute in-tree menu). + */ export function LanguageSwitcher({ className }: LanguageSwitcherProps) { const { i18n } = useTranslation() + const rootRef = useRef(null) + const [open, setOpen] = useState(false) const languages = [ { code: 'zh', name: '中文' }, @@ -25,37 +30,66 @@ export function LanguageSwitcher({ className }: LanguageSwitcherProps) { const currentLangCode = i18n.language?.split('-')[0] || 'zh' const currentLanguage = languages.find((lang) => lang.code === currentLangCode) || languages[0] + useEffect(() => { + if (!open) { + return + } + + const handlePointerDown = (event: MouseEvent) => { + if (!rootRef.current?.contains(event.target as Node)) { + setOpen(false) + } + } + + document.addEventListener('mousedown', handlePointerDown) + return () => { + document.removeEventListener('mousedown', handlePointerDown) + } + }, [open]) + const changeLanguage = (langCode: string) => { - i18n.changeLanguage(langCode) + void i18n.changeLanguage(langCode) + setOpen(false) } return ( - - - - - - {languages.map((lang) => ( - changeLanguage(lang.code)} - className={cn( - 'cursor-pointer rounded-md px-3 py-2', - currentLangCode === lang.code ? 'bg-accent' : '' - )} +
+ + {open ? ( +
+
- {lang.name} - - ))} - - + {languages.map((lang) => ( + + ))} +
+
+ ) : null} +
) } diff --git a/web/src/shared/components/route-error.test.ts b/web/src/shared/components/route-error.test.ts new file mode 100644 index 00000000..622ee774 --- /dev/null +++ b/web/src/shared/components/route-error.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest' +import * as mod from './route-error' + +describe('route-error module', () => { + it('exports the RouteError component', () => { + expect(mod.RouteError).toBeTypeOf('function') + }) +}) diff --git a/web/src/shared/components/route-error.tsx b/web/src/shared/components/route-error.tsx new file mode 100644 index 00000000..818261ea --- /dev/null +++ b/web/src/shared/components/route-error.tsx @@ -0,0 +1,29 @@ +import { useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { Button } from '@/shared/ui/button' + +interface RouteErrorProps { + error: Error + reset: () => void +} + +/** + * Recoverable route error UI (replaces TanStack default «Something went wrong»). + */ +export function RouteError({ error, reset }: RouteErrorProps) { + const { t } = useTranslation() + + useEffect(() => { + console.error('[RouteError]', error) + }, [error]) + + return ( +
+

{t('routeError.title')}

+

{t('routeError.description')}

+ +
+ ) +} diff --git a/web/src/shared/components/toaster.tsx b/web/src/shared/components/toaster.tsx index b405dae2..da7a5da6 100644 --- a/web/src/shared/components/toaster.tsx +++ b/web/src/shared/components/toaster.tsx @@ -1,29 +1,39 @@ +import { createPortal } from 'react-dom' import { Toaster as Sonner } from 'sonner' +import { getPortalContainer } from '@/shared/lib/portal-container' import { CENTER_TOASTER_ID } from '@/shared/lib/toast' export function Toaster() { - return ( - + const node = ( +
+ +
) + + const host = typeof document !== 'undefined' ? getPortalContainer() : undefined + if (host) { + return createPortal(node, host) + } + return node } diff --git a/web/src/shared/hooks/use-skill-queries.ts b/web/src/shared/hooks/use-skill-queries.ts index 63642de6..7059cddd 100644 --- a/web/src/shared/hooks/use-skill-queries.ts +++ b/web/src/shared/hooks/use-skill-queries.ts @@ -1,4 +1,4 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query' import type { SkillSummary, SkillDetail, SkillVersion, SkillVersionDetail, SkillVersionCompare, SkillFile, SearchParams, PagedResponse, PublishResult } from '@/api/types' import { fetchJson, fetchText, getCsrfHeaders, skillLifecycleApi, WEB_API_PREFIX } from '@/api/client' import { clearDeletedSkillQueries } from '@/features/skill/skill-delete-flow' @@ -64,6 +64,9 @@ export function useSearchSkills(params: SearchParams) { queryKey: ['skills', 'search', params], queryFn: () => searchSkills(params), enabled: params.starredOnly !== true, + // Keep prior results while typing/debouncing so the grid is not swapped for + // skeletons (unmount churn that races header portals under React 19). + placeholderData: keepPreviousData, }) } diff --git a/web/src/shared/lib/dismiss-open-overlays.test.ts b/web/src/shared/lib/dismiss-open-overlays.test.ts new file mode 100644 index 00000000..2c95a9eb --- /dev/null +++ b/web/src/shared/lib/dismiss-open-overlays.test.ts @@ -0,0 +1,37 @@ +/** + * @vitest-environment jsdom + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { dismissOpenOverlays } from './dismiss-open-overlays' + +describe('dismissOpenOverlays', () => { + afterEach(() => { + document.body.innerHTML = '' + vi.restoreAllMocks() + }) + + it('does nothing when no open overlay is present', () => { + const dispatch = vi.spyOn(document.body, 'dispatchEvent') + dismissOpenOverlays() + expect(dispatch).not.toHaveBeenCalled() + }) + + it('pointer-dismisses dialog overlay when an open dialog is present', () => { + const overlay = document.createElement('div') + overlay.setAttribute('data-radix-dialog-overlay', '') + document.body.appendChild(overlay) + + const dialog = document.createElement('div') + dialog.setAttribute('role', 'dialog') + dialog.setAttribute('data-state', 'open') + document.body.appendChild(dialog) + + const overlayDispatch = vi.spyOn(overlay, 'dispatchEvent') + const bodyDispatch = vi.spyOn(document.body, 'dispatchEvent') + + dismissOpenOverlays() + + expect(overlayDispatch.mock.calls.some(([event]) => (event as Event).type === 'pointerdown')).toBe(true) + expect(bodyDispatch.mock.calls.some(([event]) => (event as Event).type === 'pointerdown')).toBe(true) + }) +}) diff --git a/web/src/shared/lib/dismiss-open-overlays.ts b/web/src/shared/lib/dismiss-open-overlays.ts new file mode 100644 index 00000000..9b1cf8db --- /dev/null +++ b/web/src/shared/lib/dismiss-open-overlays.ts @@ -0,0 +1,55 @@ +const OPEN_OVERLAY_SELECTOR = [ + '[data-radix-select-content][data-state="open"]', + '[data-radix-dropdown-menu-content][data-state="open"]', + '[data-radix-dialog-content][data-state="open"]', + '[role="listbox"][data-state="open"]', + '[role="menu"][data-state="open"]', + '[role="dialog"][data-state="open"]', +].join(',') + +function dispatchPointer(target: EventTarget, type: 'pointerdown' | 'pointerup') { + target.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + cancelable: true, + pointerType: 'mouse', + button: 0, + buttons: type === 'pointerdown' ? 1 : 0, + clientX: 0, + clientY: 0, + }), + ) +} + +/** + * Dismiss open Radix overlays before a route unmounts their triggers. + * + * Prefer pointer/outside-dismiss (Radix listens for these) over synthetic Escape, + * which is often ignored because `isTrusted === false`. + * + * Call only on pathname changes (not search debounce) to avoid closing UI while + * typing on /search. + */ +export function dismissOpenOverlays(): void { + if (typeof document === 'undefined') { + return + } + + if (!document.querySelector(OPEN_OVERLAY_SELECTOR)) { + return + } + + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur() + } + + // Dialog: overlay pointerdown is the trusted dismiss path in Radix. + document.querySelectorAll('[data-radix-dialog-overlay]').forEach((overlay) => { + dispatchPointer(overlay, 'pointerdown') + dispatchPointer(overlay, 'pointerup') + }) + + // Select / DropdownMenu: outside pointerdown on body closes open content. + dispatchPointer(document.body, 'pointerdown') + dispatchPointer(document.body, 'pointerup') +} diff --git a/web/src/shared/lib/document-language.test.ts b/web/src/shared/lib/document-language.test.ts new file mode 100644 index 00000000..744ab6a7 --- /dev/null +++ b/web/src/shared/lib/document-language.test.ts @@ -0,0 +1,28 @@ +/** @vitest-environment jsdom */ + +import { afterEach, describe, expect, it } from 'vitest' +import { syncDocumentLanguage } from './document-language' + +describe('syncDocumentLanguage', () => { + afterEach(() => { + document.documentElement.lang = '' + document.documentElement.removeAttribute('translate') + document.documentElement.classList.remove('notranslate') + document.body.innerHTML = '' + }) + + it('maps zh* to zh-CN and marks html/root as notranslate', () => { + document.body.innerHTML = '
' + syncDocumentLanguage('zh') + expect(document.documentElement.lang).toBe('zh-CN') + expect(document.documentElement.getAttribute('translate')).toBe('no') + expect(document.documentElement.classList.contains('notranslate')).toBe(true) + expect(document.getElementById('root')?.getAttribute('translate')).toBe('no') + }) + + it('uses the primary subtag for other locales', () => { + document.body.innerHTML = '
' + syncDocumentLanguage('ru-RU') + expect(document.documentElement.lang).toBe('ru') + }) +}) diff --git a/web/src/shared/lib/document-language.ts b/web/src/shared/lib/document-language.ts new file mode 100644 index 00000000..2f0f28bd --- /dev/null +++ b/web/src/shared/lib/document-language.ts @@ -0,0 +1,26 @@ +/** + * Keep aligned with the in-app i18n locale and discourage Chrome / + * Google automatic translation from rewriting #root (React insertBefore crashes). + */ +export function syncDocumentLanguage(language: string) { + if (typeof document === 'undefined') { + return + } + + const primary = language.split('-')[0]?.toLowerCase() || 'en' + const lang = primary === 'zh' ? 'zh-CN' : primary + const html = document.documentElement + + if (html.lang !== lang) { + html.lang = lang + } + + html.setAttribute('translate', 'no') + html.classList.add('notranslate') + + const root = document.getElementById('root') + if (root) { + root.setAttribute('translate', 'no') + root.classList.add('notranslate') + } +} diff --git a/web/src/shared/lib/navigate-after-overlays.test.ts b/web/src/shared/lib/navigate-after-overlays.test.ts new file mode 100644 index 00000000..e6dfb377 --- /dev/null +++ b/web/src/shared/lib/navigate-after-overlays.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { navigateAfterOverlays } from './navigate-after-overlays' + +describe('navigateAfterOverlays', () => { + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('navigates after two animation frames', () => { + const navigate = vi.fn() + const frames: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + frames.push(cb) + return frames.length + }) + + navigateAfterOverlays(navigate) + + expect(navigate).not.toHaveBeenCalled() + expect(frames).toHaveLength(1) + frames[0]?.(0) + expect(frames).toHaveLength(2) + expect(navigate).not.toHaveBeenCalled() + frames[1]?.(0) + expect(navigate).toHaveBeenCalledOnce() + }) +}) diff --git a/web/src/shared/lib/navigate-after-overlays.ts b/web/src/shared/lib/navigate-after-overlays.ts new file mode 100644 index 00000000..2434d62d --- /dev/null +++ b/web/src/shared/lib/navigate-after-overlays.ts @@ -0,0 +1,21 @@ +import { dismissOpenOverlays } from './dismiss-open-overlays' + +/** + * Close overlays, then navigate on the next paint frames so Dialog/Select + * teardown does not race React 19 route unmount (removeChild / insertBefore). + */ +export function navigateAfterOverlays(navigate: () => void): void { + dismissOpenOverlays() + + const raf = globalThis.requestAnimationFrame + if (typeof raf !== 'function') { + navigate() + return + } + + raf(() => { + raf(() => { + navigate() + }) + }) +} diff --git a/web/src/shared/lib/portal-container.test.ts b/web/src/shared/lib/portal-container.test.ts new file mode 100644 index 00000000..b27b7b07 --- /dev/null +++ b/web/src/shared/lib/portal-container.test.ts @@ -0,0 +1,21 @@ +/** @vitest-environment jsdom */ + +import { afterEach, describe, expect, it } from 'vitest' +import { getPortalContainer, PORTAL_ROOT_ID } from './portal-container' + +describe('getPortalContainer', () => { + afterEach(() => { + document.getElementById(PORTAL_ROOT_ID)?.remove() + }) + + it('returns undefined when the host is missing', () => { + expect(getPortalContainer()).toBeUndefined() + }) + + it('returns the dedicated portal host when present', () => { + const host = document.createElement('div') + host.id = PORTAL_ROOT_ID + document.body.appendChild(host) + expect(getPortalContainer()).toBe(host) + }) +}) diff --git a/web/src/shared/lib/portal-container.ts b/web/src/shared/lib/portal-container.ts new file mode 100644 index 00000000..564f9a95 --- /dev/null +++ b/web/src/shared/lib/portal-container.ts @@ -0,0 +1,16 @@ +const PORTAL_ROOT_ID = 'skillhub-portals' + +/** + * Shared host for Dialog/Sonner portals so they do not fight React's `#root` + * reconciliation on `document.body` (insertBefore / removeChild races). + * Select/DropdownMenu render in-tree (no Portal) by design. + */ +export function getPortalContainer(): HTMLElement | undefined { + if (typeof document === 'undefined') { + return undefined + } + + return document.getElementById(PORTAL_ROOT_ID) ?? undefined +} + +export { PORTAL_ROOT_ID } diff --git a/web/src/shared/lib/toast.ts b/web/src/shared/lib/toast.ts index b06bfb5c..2d1fcb00 100644 --- a/web/src/shared/lib/toast.ts +++ b/web/src/shared/lib/toast.ts @@ -22,21 +22,42 @@ function withDefaultToaster(options?: ExternalToast): ExternalToast { } } +/** + * Sonner applies toast state with ReactDOM.flushSync. Calling that mid-commit + * (e.g. QueryCache onError while /search remounts cards) races React 19 DOM + * reconciliation → removeChild / insertBefore. Defer past the current turn. + */ +function scheduleToast(run: () => void) { + if (typeof globalThis.window === 'undefined') { + run() + return + } + globalThis.setTimeout(run, 0) +} + export const toast = { success: (message: string, description?: string, options?: ExternalToast) => { - sonnerToast.success(message, { description, ...withDefaultToaster(options) }) + scheduleToast(() => { + sonnerToast.success(message, { description, ...withDefaultToaster(options) }) + }) }, error: (message: string, description?: string, options?: ExternalToast) => { - sonnerToast.error(truncateErrorMessage(message) ?? message, { - description: truncateErrorMessage(description), - ...withDefaultToaster(options), + scheduleToast(() => { + sonnerToast.error(truncateErrorMessage(message) ?? message, { + description: truncateErrorMessage(description), + ...withDefaultToaster(options), + }) }) }, warning: (message: string, description?: string, options?: ExternalToast) => { - sonnerToast.warning(message, { description, ...withDefaultToaster(options) }) + scheduleToast(() => { + sonnerToast.warning(message, { description, ...withDefaultToaster(options) }) + }) }, info: (message: string, description?: string, options?: ExternalToast) => { - sonnerToast.info(message, { description, ...withDefaultToaster(options) }) + scheduleToast(() => { + sonnerToast.info(message, { description, ...withDefaultToaster(options) }) + }) }, promise: ( promise: Promise, diff --git a/web/src/shared/ui/dialog.test.ts b/web/src/shared/ui/dialog.test.ts deleted file mode 100644 index 96184ba0..00000000 --- a/web/src/shared/ui/dialog.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - Dialog, - DialogTrigger, - DialogContent, - DialogHeader, - DialogFooter, - DialogTitle, - DialogDescription, -} from './dialog' - -describe('Dialog components', () => { - it('exports all dialog sub-components', () => { - expect(Dialog).toBeDefined() - expect(DialogTrigger).toBeDefined() - expect(DialogContent).toBeDefined() - expect(DialogHeader).toBeDefined() - expect(DialogFooter).toBeDefined() - expect(DialogTitle).toBeDefined() - expect(DialogDescription).toBeDefined() - }) - - it('sets displayName on forwardRef components', () => { - expect(DialogTrigger.displayName).toBe('DialogTrigger') - expect(DialogContent.displayName).toBe('DialogContent') - expect(DialogTitle.displayName).toBe('DialogTitle') - expect(DialogDescription.displayName).toBe('DialogDescription') - }) - - it('sets displayName on function components', () => { - expect(DialogHeader.displayName).toBe('DialogHeader') - expect(DialogFooter.displayName).toBe('DialogFooter') - }) -}) diff --git a/web/src/shared/ui/dialog.test.tsx b/web/src/shared/ui/dialog.test.tsx new file mode 100644 index 00000000..088ce4c5 --- /dev/null +++ b/web/src/shared/ui/dialog.test.tsx @@ -0,0 +1,81 @@ +/** @vitest-environment jsdom */ + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} from './dialog' + +describe('Dialog components', () => { + afterEach(() => { + cleanup() + }) + + it('exports all dialog sub-components', () => { + expect(Dialog).toBeDefined() + expect(DialogTrigger).toBeDefined() + expect(DialogContent).toBeDefined() + expect(DialogHeader).toBeDefined() + expect(DialogFooter).toBeDefined() + expect(DialogTitle).toBeDefined() + expect(DialogDescription).toBeDefined() + }) + + it('sets displayName on forwardRef components', () => { + expect(DialogTrigger.displayName).toBe('DialogTrigger') + expect(DialogContent.displayName).toBe('DialogContent') + expect(DialogTitle.displayName).toBe('DialogTitle') + expect(DialogDescription.displayName).toBe('DialogDescription') + }) + + it('sets displayName on function components', () => { + expect(DialogHeader.displayName).toBe('DialogHeader') + expect(DialogFooter.displayName).toBe('DialogFooter') + }) + + it('opens through the trigger by notifying onOpenChange', () => { + const onOpenChange = vi.fn() + + render( + + Open dialog + + + Token created + Copy the token now + + + + ) + + expect(screen.queryByRole('dialog')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Open dialog' })) + expect(onOpenChange).toHaveBeenCalledWith(true) + }) + + it('renders dialog content when open and closes via the close button', () => { + const onOpenChange = vi.fn() + + render( + + + + Confirm delete + This cannot be undone + + + + ) + + expect(screen.getByRole('dialog')).toBeTruthy() + expect(screen.getByText('Confirm delete')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + expect(onOpenChange).toHaveBeenCalledWith(false) + }) +}) diff --git a/web/src/shared/ui/dialog.tsx b/web/src/shared/ui/dialog.tsx index 38d2b1e9..99401e12 100644 --- a/web/src/shared/ui/dialog.tsx +++ b/web/src/shared/ui/dialog.tsx @@ -1,153 +1,68 @@ import * as React from 'react' -import { createPortal } from 'react-dom' +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { getPortalContainer } from '@/shared/lib/portal-container' import { cn } from '@/shared/lib/utils' -interface DialogContextValue { - open: boolean - onOpenChange: (open: boolean) => void -} +const Dialog = DialogPrimitive.Root -const DialogContext = React.createContext(undefined) +const DialogTrigger = DialogPrimitive.Trigger +DialogTrigger.displayName = DialogPrimitive.Trigger.displayName ?? 'DialogTrigger' -function useDialog() { - const context = React.useContext(DialogContext) - if (!context) { - throw new Error('Dialog components must be used within Dialog') - } - return context -} +const DialogPortal = DialogPrimitive.Portal -interface DialogProps { - open?: boolean - onOpenChange?: (open: boolean) => void - children: React.ReactNode -} +const DialogClose = DialogPrimitive.Close +DialogClose.displayName = DialogPrimitive.Close.displayName ?? 'DialogClose' -const Dialog = ({ open: controlledOpen, onOpenChange, children }: DialogProps) => { - const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false) - const open = controlledOpen ?? uncontrolledOpen - const handleOpenChange = onOpenChange ?? setUncontrolledOpen - - React.useEffect(() => { - if (!open || typeof document === 'undefined') { - return undefined - } - - const { body } = document - const previousOverflow = body.style.overflow - body.style.overflow = 'hidden' - - return () => { - body.style.overflow = previousOverflow - } - }, [open]) - - return ( - - {children} - - ) -} - -interface DialogTriggerProps extends React.ButtonHTMLAttributes { - asChild?: boolean -} - -const DialogTrigger = React.forwardRef( - ({ onClick, asChild, children, ...props }, ref) => { - const { onOpenChange } = useDialog() - - const handleClick = (e: React.MouseEvent) => { - onOpenChange(true) - onClick?.(e) - } - - if (asChild && React.isValidElement(children)) { - return React.cloneElement(children as React.ReactElement<{ onClick?: (e: React.MouseEvent) => void }>, { - onClick: handleClick, - }) - } - - return ( - - ) - } -) -DialogTrigger.displayName = 'DialogTrigger' - -const DialogPortal = ({ children }: { children: React.ReactNode }) => { - const { open } = useDialog() - if (!open) return null - if (typeof document === 'undefined') { - return null - } - return createPortal(children, document.body) -} - -const DialogOverlay = React.forwardRef>( - ({ className, ...props }, ref) => { - const { onOpenChange } = useDialog() - return ( -
onOpenChange(false)} - {...props} - /> - ) - } -) +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) DialogOverlay.displayName = 'DialogOverlay' -const DialogContent = React.forwardRef>( - ({ className, children, ...props }, ref) => { - const { onOpenChange } = useDialog() - return ( - - -
e.stopPropagation()} - {...props} +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + Close + - {children} - Close - - - - - -
-
- ) - } -) + + + + + + +)) DialogContent.displayName = 'DialogContent' const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( @@ -160,18 +75,28 @@ const DialogFooter = ({ className, ...props }: React.HTMLAttributes>( - ({ className, ...props }, ref) => ( -

- ) -) +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) DialogTitle.displayName = 'DialogTitle' -const DialogDescription = React.forwardRef>( - ({ className, ...props }, ref) => ( -

- ) -) +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) DialogDescription.displayName = 'DialogDescription' export { @@ -182,4 +107,7 @@ export { DialogFooter, DialogTitle, DialogDescription, + DialogClose, + DialogPortal, + DialogOverlay, } diff --git a/web/src/shared/ui/dropdown-menu.tsx b/web/src/shared/ui/dropdown-menu.tsx index 475d1dcb..15f5211a 100644 --- a/web/src/shared/ui/dropdown-menu.tsx +++ b/web/src/shared/ui/dropdown-menu.tsx @@ -10,18 +10,20 @@ const DropdownMenuContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, sideOffset = 4, ...props }, ref) => ( - - - + // No Portal: same React 19 removeChild mitigation as Select (in-tree Content). + )) DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName diff --git a/web/src/shared/ui/select.test.ts b/web/src/shared/ui/select.test.ts index 7bb7df1a..0768fd3a 100644 --- a/web/src/shared/ui/select.test.ts +++ b/web/src/shared/ui/select.test.ts @@ -4,6 +4,7 @@ import { SELECT_ITEM_CLASS_NAME, SELECT_SCROLL_BUTTON_CLASS_NAME, SELECT_TRIGGER_CLASS_NAME, + SelectContent, normalizeSelectValue, } from './select' @@ -24,6 +25,13 @@ describe('shared select contract', () => { expect(SELECT_CONTENT_CLASS_NAME).toContain('text-popover-foreground') expect(SELECT_ITEM_CLASS_NAME).toContain('focus:bg-accent') expect(SELECT_ITEM_CLASS_NAME).toContain('data-[disabled]:opacity-50') + expect(SELECT_CONTENT_CLASS_NAME).toContain('data-[state=open]:animate-in') + expect(SELECT_CONTENT_CLASS_NAME).not.toContain('data-[state=closed]:animate-out') + }) + + it('exports SelectContent as an in-tree content component', () => { + expect(SelectContent).toBeDefined() + expect(SelectContent.displayName).toBeDefined() }) it('keeps the dropdown and selected items visually discoverable', () => { diff --git a/web/src/shared/ui/select.tsx b/web/src/shared/ui/select.tsx index 4b441c63..f45cc4f5 100644 --- a/web/src/shared/ui/select.tsx +++ b/web/src/shared/ui/select.tsx @@ -13,9 +13,9 @@ export const SELECT_TRIGGER_CLASS_NAME = cn( export const SELECT_CONTENT_CLASS_NAME = cn( 'z-50 overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-md', - 'data-[state=open]:animate-in data-[state=closed]:animate-out', - 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', - 'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95', + // In-tree (no Portal): avoids React 19 removeChild races on route unmount. + // No exit animations: delayed unmount still races commits when Content was portaled. + 'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95', 'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2', 'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2' ) @@ -43,6 +43,7 @@ const SelectTrigger = React.forwardRef< >(({ className, children, ...props }, ref) => ( @@ -89,31 +90,32 @@ const SelectContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, children, position = 'popper', ...props }, ref) => ( - - + + - - - {children} - - - - + {children} + + + )) SelectContent.displayName = SelectPrimitive.Content.displayName