fix(web): stop React 19 portal removeChild/insertBefore crashes (#624)

* 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 <michael.neradkov@gmail.com>

* 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 <michael.neradkov@gmail.com>

* 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 <michael.neradkov@gmail.com>

* 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 <michael.neradkov@gmail.com>

* 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 <michael.neradkov@gmail.com>

* 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 <michael.neradkov@gmail.com>

---------

Signed-off-by: Mikhail Neradkov <michael.neradkov@gmail.com>
This commit is contained in:
Michael 2026-08-04 08:59:37 +03:00 committed by GitHub
parent 55e5c1e10e
commit a063090a52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 1084 additions and 336 deletions

View file

@ -1,8 +1,9 @@
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="zh-CN" translate="no" class="notranslate">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="google" content="notranslate" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' ws: wss: http://localhost:* https://localhost:*; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"
@ -14,7 +15,10 @@
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<!-- translate=no: Chrome auto-translate mutates #root and races React 19 insertBefore. -->
<div id="root" translate="no" class="notranslate"></div>
<!-- Isolated host for Dialog/Sonner portals (avoids body sibling races with #root). -->
<div id="skillhub-portals" translate="no" class="notranslate"></div>
<script type="module" src="/src/bootstrap.ts"></script>
</body>
</html>

View file

@ -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",

302
web/pnpm-lock.yaml generated
View file

@ -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

View file

@ -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 (
<div className="min-h-screen flex flex-col relative overflow-x-clip" style={{ background: 'var(--bg-page, hsl(var(--background)))' }}>
{/* Decorative gradient orb */}
<div
className="absolute top-0 right-0 w-[600px] h-[500px] rounded-full opacity-90 pointer-events-none z-0"
style={{
background: 'radial-gradient(ellipse at 70% 20%, rgba(184,94,255,0.25) 0%, rgba(106,109,255,0.15) 40%, transparent 70%)',
filter: 'blur(60px)',
}}
/>
<div className="min-h-screen flex flex-col relative" style={{ background: 'var(--bg-page, hsl(var(--background)))' }}>
{/* Clip only the decorative layer so in-tree Select/Dropdown are not cropped. */}
<div className="pointer-events-none absolute inset-0 z-0 overflow-x-clip" aria-hidden>
<div
className="absolute top-0 right-0 w-[600px] h-[500px] rounded-full opacity-90"
style={{
background: 'radial-gradient(ellipse at 70% 20%, rgba(184,94,255,0.25) 0%, rgba(106,109,255,0.15) 40%, transparent 70%)',
filter: 'blur(60px)',
}}
/>
</div>
{/* Header */}
<header className={getAppHeaderClassName(isHeaderElevated)} style={{ borderColor: 'hsl(var(--border))' }}>

View file

@ -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' {

View file

@ -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(<MarkdownRenderer content="Hello" />)
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(
<MarkdownRenderer content="[Usage](docs/usage.md)" onLinkClick={onLinkClick} />,
)
const firstRoot = container.firstElementChild
rerender(<MarkdownRenderer content="[Usage](docs/usage.md)" onLinkClick={onLinkClick} />)
expect(container.firstElementChild).toBe(firstRoot)
})
})

View file

@ -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 (
<div className={containerClassName}>
<div className={cn(containerClassName, 'notranslate')} translate="no">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkInferCodeLanguage]}
rehypePlugins={[rehypeSanitize, [rehypeHighlight, { detect: true, ignoreMissing: true }]]}
@ -207,3 +207,6 @@ export function MarkdownRenderer({ content, className, onLinkClick }: MarkdownRe
</div>
)
}
/** Memoized so collapse/expand parent state does not re-parse large skill docs. */
export const MarkdownRenderer = memo(MarkdownRendererComponent)

View file

@ -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)
})
})

View file

@ -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
}

View file

@ -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'

View file

@ -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')

View file

@ -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<StarStatus> {
const starred = await fetchJson<boolean>(`${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()

View file

@ -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",

View file

@ -1524,6 +1524,11 @@
}
}
},
"routeError": {
"title": "出现错误",
"description": "页面遇到意外错误。请重置后继续。若在无扩展的隐私窗口中仍反复出现,请反馈问题。",
"reset": "重置"
},
"notification": {
"title": "通知",
"empty": "暂无通知",

View file

@ -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() {
<div className="space-y-3">
<Label className="text-sm font-semibold font-heading">{t('publish.file')}</Label>
<UploadZone
key={selectedFile ? `${selectedFile.name}-${selectedFile.lastModified}` : 'empty'}
onFileSelect={handleFileSelect}
disabled={publishMutation.isPending}
/>

View file

@ -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<HTMLDivElement | null>(null)
const overviewSectionRef = useRef<HTMLDivElement | null>(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<HTMLAnchorElement>,
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<HTMLAnchorElement>) => {
const handleOverviewLinkClick = useCallback((href: string, event: MouseEvent<HTMLAnchorElement>) => {
handlePackageMarkdownLinkClick(href, event, documentationPath)
}
}, [documentationPath, handlePackageMarkdownLinkClick])
const handlePreviewLinkClick = (href: string, event: MouseEvent<HTMLAnchorElement>) => {
const handlePreviewLinkClick = useCallback((href: string, event: MouseEvent<HTMLAnchorElement>) => {
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() {
<div ref={overviewSectionRef} className="space-y-4">
<div
className={cn(
'relative overflow-hidden transition-[max-height] duration-300 ease-out',
'relative overflow-hidden',
!isOverviewExpanded && isOverviewCollapsible && 'rounded-2xl',
)}
style={!isOverviewExpanded && isOverviewCollapsible ? { maxHeight: `${overviewMaxHeight}px` } : undefined}

View file

@ -1,4 +1,4 @@
import { ReactNode } from 'react'
import { ReactNode, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import {
Dialog,
@ -36,11 +36,17 @@ export function ConfirmDialog({
confirmButtonTestId,
}: ConfirmDialogProps) {
const { t } = useTranslation()
const openRef = useRef(open)
openRef.current = open
const resolvedConfirmText = confirmText ?? t('dialog.confirm')
const resolvedCancelText = cancelText ?? t('dialog.cancel')
const handleConfirm = async () => {
await onConfirm()
onOpenChange(false)
// Skip close if the caller already closed (e.g. publish success + navigate).
if (openRef.current) {
onOpenChange(false)
}
}
return (

View file

@ -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', () => {

View file

@ -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<HTMLDivElement | null>(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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn('cursor-pointer gap-2 text-muted-foreground hover:text-foreground', className)}
>
<Globe className="h-4 w-4" />
<span className="text-sm text-inherit">{currentLanguage.name}</span>
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="flex min-w-[9rem] flex-col gap-1.5 p-2">
{languages.map((lang) => (
<DropdownMenuItem
key={lang.code}
onClick={() => changeLanguage(lang.code)}
className={cn(
'cursor-pointer rounded-md px-3 py-2',
currentLangCode === lang.code ? 'bg-accent' : ''
)}
<div ref={rootRef} className="relative">
<Button
type="button"
variant="ghost"
size="sm"
aria-expanded={open}
aria-haspopup="menu"
className={cn('cursor-pointer gap-2 text-muted-foreground hover:text-foreground', className)}
onClick={() => setOpen((current) => !current)}
>
<Globe className="h-4 w-4" />
<span className="text-sm text-inherit">{currentLanguage.name}</span>
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
</Button>
{open ? (
<div className="absolute right-0 top-full z-50 pt-2">
<div
role="menu"
className="flex min-w-[9rem] flex-col gap-1.5 rounded-md border bg-popover p-2 text-popover-foreground shadow-md"
>
{lang.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{languages.map((lang) => (
<button
key={lang.code}
type="button"
role="menuitem"
onClick={() => changeLanguage(lang.code)}
className={cn(
'cursor-pointer rounded-md px-3 py-2 text-left text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground',
currentLangCode === lang.code ? 'bg-accent' : ''
)}
>
{lang.name}
</button>
))}
</div>
</div>
) : null}
</div>
)
}

View file

@ -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')
})
})

View file

@ -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 (
<div className="flex min-h-[40vh] flex-col items-center justify-center gap-4 px-6 text-center">
<h1 className="text-xl font-semibold text-foreground">{t('routeError.title')}</h1>
<p className="max-w-md text-sm text-muted-foreground">{t('routeError.description')}</p>
<Button type="button" onClick={() => reset()}>
{t('routeError.reset')}
</Button>
</div>
)
}

View file

@ -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 (
<Sonner
id={CENTER_TOASTER_ID}
position="top-center"
className="!left-1/2 !right-auto !top-4 !-translate-x-1/2"
offset={16}
mobileOffset={16}
toastOptions={{
toasterId: CENTER_TOASTER_ID,
classNames: {
toast: 'glass-strong mx-auto w-fit max-w-[min(100vw-2rem,32rem)] border border-border/40',
title: 'text-foreground font-semibold text-center',
description: 'text-muted-foreground text-center',
content: 'w-full text-center',
actionButton: 'bg-primary text-primary-foreground',
cancelButton: 'bg-muted text-muted-foreground',
error: 'border-destructive/40',
success: 'border-emerald-500/40',
warning: 'border-amber-500/40',
info: 'border-blue-500/40',
},
}}
/>
const node = (
<div translate="no">
<Sonner
id={CENTER_TOASTER_ID}
position="top-center"
className="!left-1/2 !right-auto !top-4 !-translate-x-1/2 !z-[100]"
offset={16}
mobileOffset={16}
toastOptions={{
toasterId: CENTER_TOASTER_ID,
classNames: {
toast: 'glass-strong mx-auto w-fit max-w-[min(100vw-2rem,32rem)] border border-border/40',
title: 'text-foreground font-semibold text-center',
description: 'text-muted-foreground text-center',
content: 'w-full text-center',
actionButton: 'bg-primary text-primary-foreground',
cancelButton: 'bg-muted text-muted-foreground',
error: 'border-destructive/40',
success: 'border-emerald-500/40',
warning: 'border-amber-500/40',
info: 'border-blue-500/40',
},
}}
/>
</div>
)
const host = typeof document !== 'undefined' ? getPortalContainer() : undefined
if (host) {
return createPortal(node, host)
}
return node
}

View file

@ -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,
})
}

View file

@ -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)
})
})

View file

@ -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')
}

View file

@ -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 = '<div id="root"></div>'
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 = '<div id="root"></div>'
syncDocumentLanguage('ru-RU')
expect(document.documentElement.lang).toBe('ru')
})
})

View file

@ -0,0 +1,26 @@
/**
* Keep <html lang> 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')
}
}

View file

@ -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()
})
})

View file

@ -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()
})
})
}

View file

@ -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)
})
})

View file

@ -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 }

View file

@ -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: <T,>(
promise: Promise<T>,

View file

@ -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')
})
})

View file

@ -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(
<Dialog open={false} onOpenChange={onOpenChange}>
<DialogTrigger>Open dialog</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Token created</DialogTitle>
<DialogDescription>Copy the token now</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
)
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(
<Dialog open onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm delete</DialogTitle>
<DialogDescription>This cannot be undone</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
)
expect(screen.getByRole('dialog')).toBeTruthy()
expect(screen.getByText('Confirm delete')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(onOpenChange).toHaveBeenCalledWith(false)
})
})

View file

@ -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<DialogContextValue | undefined>(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 (
<DialogContext.Provider value={{ open, onOpenChange: handleOpenChange }}>
{children}
</DialogContext.Provider>
)
}
interface DialogTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean
}
const DialogTrigger = React.forwardRef<HTMLButtonElement, DialogTriggerProps>(
({ onClick, asChild, children, ...props }, ref) => {
const { onOpenChange } = useDialog()
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
onOpenChange(true)
onClick?.(e)
}
if (asChild && React.isValidElement(children)) {
return React.cloneElement(children as React.ReactElement<{ onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void }>, {
onClick: handleClick,
})
}
return (
<button ref={ref} onClick={handleClick} {...props}>
{children}
</button>
)
}
)
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<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { onOpenChange } = useDialog()
return (
<div
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
className
)}
onClick={() => onOpenChange(false)}
{...props}
/>
)
}
)
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
translate="no"
className={cn('fixed inset-0 z-50 bg-black/60 backdrop-blur-sm', className)}
{...props}
/>
))
DialogOverlay.displayName = 'DialogOverlay'
const DialogContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, children, ...props }, ref) => {
const { onOpenChange } = useDialog()
return (
<DialogPortal>
<DialogOverlay />
<div
ref={ref}
role="dialog"
aria-modal="true"
className={cn(
'fixed left-1/2 top-1/2 z-50 grid max-h-[calc(100vh-2rem)] w-[min(calc(100vw-2rem),32rem)] -translate-x-1/2 -translate-y-1/2 gap-4 overflow-y-auto rounded-2xl border border-border/60 bg-card p-8 shadow-card',
className
)}
onClick={(e) => e.stopPropagation()}
{...props}
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal container={getPortalContainer()}>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
translate="no"
className={cn(
'fixed left-1/2 top-1/2 z-50 grid max-h-[calc(100vh-2rem)] w-[min(calc(100vw-2rem),32rem)] -translate-x-1/2 -translate-y-1/2 gap-4 overflow-y-auto rounded-2xl border border-border/60 bg-card p-8 shadow-card',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<span className="sr-only">Close</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
>
{children}
<button
onClick={() => onOpenChange(false)}
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
>
<span className="sr-only">Close</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
</div>
</DialogPortal>
)
}
)
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = 'DialogContent'
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
@ -160,18 +75,28 @@ const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivEleme
)
DialogFooter.displayName = 'DialogFooter'
const DialogTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h2 ref={ref} className={cn('text-center text-xl font-bold font-heading leading-none tracking-tight', className)} {...props} />
)
)
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-center text-xl font-bold font-heading leading-none tracking-tight', className)}
{...props}
/>
))
DialogTitle.displayName = 'DialogTitle'
const DialogDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-center text-sm text-muted-foreground', className)} {...props} />
)
)
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-center text-sm text-muted-foreground', className)}
{...props}
/>
))
DialogDescription.displayName = 'DialogDescription'
export {
@ -182,4 +107,7 @@ export {
DialogFooter,
DialogTitle,
DialogDescription,
DialogClose,
DialogPortal,
DialogOverlay,
}

View file

@ -10,18 +10,20 @@ const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 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 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',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
// No Portal: same React 19 removeChild mitigation as Select (in-tree Content).
<DropdownMenuPrimitive.Content
ref={ref}
translate="no"
sideOffset={sideOffset}
className={cn(
// No exit animations: delayed unmount races React 19 commits.
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
'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',
className
)}
{...props}
/>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName

View file

@ -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', () => {

View file

@ -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) => (
<SelectPrimitive.Trigger
ref={ref}
translate="no"
className={cn(SELECT_TRIGGER_CLASS_NAME, className)}
{...props}
>
@ -89,31 +90,32 @@ const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
// No Portal: Content stays in the React tree with its trigger so route/Dialog
// unmount cannot orphan a body/#skillhub-portals node (removeChild).
<SelectPrimitive.Content
ref={ref}
translate="no"
className={cn(
SELECT_CONTENT_CLASS_NAME,
position === 'popper'
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
SELECT_CONTENT_CLASS_NAME,
'p-1',
position === 'popper'
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
&& 'h-[var(--radix-select-trigger-height)] min-w-[var(--radix-select-trigger-width)]'
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper'
&& 'h-[var(--radix-select-trigger-height)] min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
))
SelectContent.displayName = SelectPrimitive.Content.displayName