fix(NodeSwitcher): move all hooks before conditional return to comply with React hooks rules
Build and Deploy / Build and Push Docker Image (push) Successful in 2m1s
Build and Deploy / Build and Push Docker Image (push) Successful in 2m1s
- All hooks (useState, useCallback, useEffect) must be called before any conditional return - Add document check in updateRoleState - Merge SSR and isMaster checks into single return at the end
This commit is contained in:
@@ -20,15 +20,6 @@ export function NodeSwitcher() {
|
|||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true)
|
|
||||||
// 在客户端初始化时从 localStorage 读取选中的节点
|
|
||||||
setSelected(activeInterconnectNodeId || 'local')
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// SSR 时返回 null,避免 hydration 错误
|
|
||||||
if (!mounted) return null
|
|
||||||
|
|
||||||
const fetchNodes = useCallback(async () => {
|
const fetchNodes = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getNodes()
|
const res = await getNodes()
|
||||||
@@ -48,15 +39,23 @@ export function NodeSwitcher() {
|
|||||||
} else {
|
} else {
|
||||||
setNodes([])
|
setNodes([])
|
||||||
setSelected('local')
|
setSelected('local')
|
||||||
const traveling = !!document.cookie.match(/(?:^| )active_interconnect_node_id=([^;]*)/)
|
if (typeof document !== 'undefined') {
|
||||||
if (activeInterconnectNodeId && !traveling) {
|
const traveling = !!document.cookie.match(/(?:^| )active_interconnect_node_id=([^;]*)/)
|
||||||
setActiveInterconnectNodeId('')
|
if (activeInterconnectNodeId && !traveling) {
|
||||||
|
setActiveInterconnectNodeId('')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[fetchNodes],
|
[fetchNodes],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
// 在客户端初始化时从 localStorage 读取选中的节点
|
||||||
|
setSelected(activeInterconnectNodeId || 'local')
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -85,47 +84,25 @@ export function NodeSwitcher() {
|
|||||||
}
|
}
|
||||||
}, [isMaster, fetchNodes])
|
}, [isMaster, fetchNodes])
|
||||||
|
|
||||||
if (!isMaster) return null
|
|
||||||
|
|
||||||
const handleSelect = (val: string) => {
|
|
||||||
setSelected(val)
|
|
||||||
setOpen(false)
|
|
||||||
if (val === 'local') {
|
|
||||||
setActiveInterconnectNodeId('')
|
|
||||||
} else {
|
|
||||||
const name = nodes.find((n) => n.id === val)?.name || ''
|
|
||||||
setActiveInterconnectNodeId(val, name)
|
|
||||||
}
|
|
||||||
window.location.reload()
|
|
||||||
}
|
|
||||||
|
|
||||||
const allOptions = [
|
|
||||||
{ value: 'local', label: '本机节点' },
|
|
||||||
...nodes.map((n) => ({ value: n.id, label: n.name })),
|
|
||||||
]
|
|
||||||
|
|
||||||
const currentLabel = allOptions.find((o) => o.value === selected)?.label || '本机节点'
|
|
||||||
|
|
||||||
const updateMenuPosition = () => {
|
|
||||||
const el = buttonRef.current
|
|
||||||
if (!el) return
|
|
||||||
const rect = el.getBoundingClientRect()
|
|
||||||
const viewportH = window.innerHeight
|
|
||||||
const spaceBelow = viewportH - rect.bottom
|
|
||||||
const openUp = spaceBelow < 220 && rect.top > spaceBelow
|
|
||||||
setMenuStyle({
|
|
||||||
position: 'fixed',
|
|
||||||
left: rect.left,
|
|
||||||
width: Math.max(rect.width, 140),
|
|
||||||
zIndex: 80,
|
|
||||||
...(openUp
|
|
||||||
? { bottom: viewportH - rect.top + 6, top: 'auto' }
|
|
||||||
: { top: rect.bottom + 6, bottom: 'auto' }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
const updateMenuPosition = () => {
|
||||||
|
const el = buttonRef.current
|
||||||
|
if (!el) return
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
const viewportH = window.innerHeight
|
||||||
|
const spaceBelow = viewportH - rect.bottom
|
||||||
|
const openUp = spaceBelow < 220 && rect.top > spaceBelow
|
||||||
|
setMenuStyle({
|
||||||
|
position: 'fixed',
|
||||||
|
left: rect.left,
|
||||||
|
width: Math.max(rect.width, 140),
|
||||||
|
zIndex: 80,
|
||||||
|
...(openUp
|
||||||
|
? { bottom: viewportH - rect.top + 6, top: 'auto' }
|
||||||
|
: { top: rect.bottom + 6, bottom: 'auto' }),
|
||||||
|
})
|
||||||
|
}
|
||||||
updateMenuPosition()
|
updateMenuPosition()
|
||||||
const onDoc = (e: MouseEvent) => {
|
const onDoc = (e: MouseEvent) => {
|
||||||
const t = e.target as Node
|
const t = e.target as Node
|
||||||
@@ -148,6 +125,28 @@ export function NodeSwitcher() {
|
|||||||
}
|
}
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
|
// SSR 时或非 master 节点时返回 null
|
||||||
|
if (!mounted || !isMaster) return null
|
||||||
|
|
||||||
|
const handleSelect = (val: string) => {
|
||||||
|
setSelected(val)
|
||||||
|
setOpen(false)
|
||||||
|
if (val === 'local') {
|
||||||
|
setActiveInterconnectNodeId('')
|
||||||
|
} else {
|
||||||
|
const name = nodes.find((n) => n.id === val)?.name || ''
|
||||||
|
setActiveInterconnectNodeId(val, name)
|
||||||
|
}
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
const allOptions = [
|
||||||
|
{ value: 'local', label: '本机节点' },
|
||||||
|
...nodes.map((n) => ({ value: n.id, label: n.name })),
|
||||||
|
]
|
||||||
|
|
||||||
|
const currentLabel = allOptions.find((o) => o.value === selected)?.label || '本机节点'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user