现象
dev 下每次打开文章页,终端都会打印 2 次 React 的 "Invalid hook call"。
生产构建没有;首页、/thoughts/、/friends/ 也没有。
根因(用临时 Vite 插件抓的调用栈定位)
不是 React 多副本,也不是 hooks 写法有问题,而是 Astro 的渲染器探测:
1. TableOfContents 首屏 headings 为空 → `return null`(数据在 useEffect 里填,
SSR 期不执行)
2. @astrojs/react 的 check 会试渲染一次,拿到 null 就认不出这是 React 组件 → false
3. Astro 转向下一个渲染器 @astrojs/mdx
4. MDX 的 check 把组件**当普通函数裸调** `await Component({...})`,不在 React
渲染树里 → useState 找不到 dispatcher → 报 Invalid hook call → 抛错被 catch
5. 两个渲染器都说不是自己的,Astro 最终仍用 React 正确渲染了组件
所以页面和组件一直是对的,只有日志是错的。生产构建没有是因为 React 生产版
剥掉了这段 dev-only 警告(栈里是 react.development.js)。
Hero 之类的组件不报,是因为它们探测时返回真实 JSX,第 2 步就被 React 认出来了。
修法
去掉 `if (headings.length === 0) return null`,改为始终返回 <nav>,无标题时用
`hidden` 收起,视觉结果不变。这样第 2 步就能认出是 React 组件,根本不会走到
MDX 的探测。代价是文章页 SSR 多出 2 个 display:none 的空 <nav>(两个 TOC 实例各一个),
以及 island 上多了 Astro 的 await-children 标记。
另一条路(未采用):项目里 0 个 .mdx,直接删掉 @astrojs/mdx 也能消除告警,
且零 DOM 变化。选择保留 MDX 是为了不因一个纯噪音的警告砍掉一个能力。
验证
dev 下逐个请求 5 类页面(含之前必现的 2 个文章页)告警数全为 0;
生产构建与基线的差异只剩本次有意的隐藏 nav、资源哈希和 island uid;
pnpm build 41 页 + pagefind 通过;astro check 仍是 1 error/0 warnings(原有报错)。
330 lines
12 KiB
TypeScript
330 lines
12 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect, useRef, useCallback } from "react"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
interface TocItem {
|
|
id: string
|
|
text: string
|
|
level: number
|
|
}
|
|
|
|
interface TableOfContentsProps {
|
|
showHeader?: boolean
|
|
}
|
|
|
|
export function TableOfContents({ showHeader = true }: TableOfContentsProps) {
|
|
const [headings, setHeadings] = useState<TocItem[]>([])
|
|
const [activeId, setActiveId] = useState<string>("")
|
|
const navRef = useRef<HTMLElement | null>(null)
|
|
const observerRef = useRef<IntersectionObserver | null>(null)
|
|
const headingElementsRef = useRef<Map<string, IntersectionObserverEntry>>(new Map())
|
|
// Pre-measured document-coord top of every heading. Refreshed on resize / page
|
|
// load — never read inside the IO callback to avoid forced sync layout while
|
|
// the user scrolls.
|
|
const headingOffsetsRef = useRef<Array<{ id: string; top: number }>>([])
|
|
const tocAutoScrollTimerRef = useRef<number | null>(null)
|
|
const programmaticTargetRef = useRef<string>("")
|
|
const programmaticLockRef = useRef(false)
|
|
const lockTimerRef = useRef<number | null>(null)
|
|
|
|
const unlockProgrammaticLock = useCallback(() => {
|
|
programmaticLockRef.current = false
|
|
programmaticTargetRef.current = ""
|
|
if (lockTimerRef.current !== null) {
|
|
window.clearTimeout(lockTimerRef.current)
|
|
lockTimerRef.current = null
|
|
}
|
|
}, [])
|
|
|
|
const clearTocAutoScrollTimer = useCallback(() => {
|
|
if (tocAutoScrollTimerRef.current !== null) {
|
|
window.clearTimeout(tocAutoScrollTimerRef.current)
|
|
tocAutoScrollTimerRef.current = null
|
|
}
|
|
}, [])
|
|
|
|
const collectHeadings = useCallback(() => {
|
|
const article = document.querySelector("article")
|
|
if (!article) return
|
|
|
|
const elements = article.querySelectorAll("h2, h3")
|
|
const items: TocItem[] = []
|
|
elements.forEach((el) => {
|
|
const id = el.id
|
|
if (!id) return
|
|
const clone = el.cloneNode(true) as HTMLElement
|
|
clone.querySelectorAll(".anchor, .anchor-icon").forEach((a) => a.remove())
|
|
const text = clone.textContent?.trim() || ""
|
|
if (!text) return
|
|
const level = parseInt(el.tagName[1], 10)
|
|
items.push({ id, text, level })
|
|
})
|
|
setHeadings(items)
|
|
return items
|
|
}, [])
|
|
|
|
const measureOffsets = useCallback((items: TocItem[]) => {
|
|
const scrollY = window.scrollY
|
|
const offsets: Array<{ id: string; top: number }> = []
|
|
items.forEach((item) => {
|
|
const el = document.getElementById(item.id)
|
|
if (!el) return
|
|
offsets.push({ id: item.id, top: el.getBoundingClientRect().top + scrollY })
|
|
})
|
|
offsets.sort((a, b) => a.top - b.top)
|
|
headingOffsetsRef.current = offsets
|
|
}, [])
|
|
|
|
const setupObserver = useCallback(
|
|
(items: TocItem[]) => {
|
|
if (observerRef.current) {
|
|
observerRef.current.disconnect()
|
|
}
|
|
headingElementsRef.current = new Map()
|
|
|
|
observerRef.current = new IntersectionObserver(
|
|
(entries) => {
|
|
entries.forEach((entry) => {
|
|
headingElementsRef.current.set(entry.target.id, entry)
|
|
})
|
|
|
|
if (programmaticLockRef.current) {
|
|
const targetId = programmaticTargetRef.current
|
|
if (targetId) {
|
|
// While a click-driven smooth scroll is in flight, keep activeId
|
|
// pinned to the target. Unlock is driven by `scrollend` / 2500ms
|
|
// hard timer / user gesture — never by IO geometry, which would
|
|
// either flicker (entry-based) or cost a sync layout read
|
|
// (rect-based, the original implementation).
|
|
setActiveId(targetId)
|
|
return
|
|
}
|
|
unlockProgrammaticLock()
|
|
}
|
|
|
|
const visibleHeadings: IntersectionObserverEntry[] = []
|
|
headingElementsRef.current.forEach((entry) => {
|
|
if (entry.isIntersecting) visibleHeadings.push(entry)
|
|
})
|
|
|
|
if (visibleHeadings.length > 0) {
|
|
const sorted = visibleHeadings.sort(
|
|
(a, b) => a.boundingClientRect.top - b.boundingClientRect.top,
|
|
)
|
|
setActiveId(sorted[0].target.id)
|
|
} else {
|
|
// Fallback: use pre-measured offsets instead of reading layout for
|
|
// every heading on every callback (the original code triggered a
|
|
// forced reflow per heading per scroll tick — the main mobile-jank
|
|
// culprit reported by Codex).
|
|
const scrollY = window.scrollY
|
|
const offsets = headingOffsetsRef.current
|
|
let closestId = ""
|
|
for (const o of offsets) {
|
|
if (o.top <= scrollY + 100) closestId = o.id
|
|
else break
|
|
}
|
|
if (closestId) setActiveId(closestId)
|
|
}
|
|
},
|
|
{
|
|
rootMargin: "-80px 0px -60% 0px",
|
|
threshold: 0,
|
|
},
|
|
)
|
|
|
|
items.forEach((item) => {
|
|
const el = document.getElementById(item.id)
|
|
if (el) observerRef.current?.observe(el)
|
|
})
|
|
},
|
|
[unlockProgrammaticLock],
|
|
)
|
|
|
|
useEffect(() => {
|
|
let resizeRaf = 0
|
|
let remeasureRaf = 0
|
|
let currentItems: TocItem[] = []
|
|
let articleObserver: ResizeObserver | null = null
|
|
|
|
const remeasure = () => {
|
|
if (currentItems.length > 0) measureOffsets(currentItems)
|
|
}
|
|
|
|
const scheduleRemeasure = () => {
|
|
if (remeasureRaf) cancelAnimationFrame(remeasureRaf)
|
|
remeasureRaf = requestAnimationFrame(remeasure)
|
|
}
|
|
|
|
const observeArticleResize = () => {
|
|
articleObserver?.disconnect()
|
|
const article = document.querySelector("article")
|
|
if (!article || typeof ResizeObserver === "undefined") return
|
|
// Markdown images load lazily and rarely declare width/height, so the
|
|
// article reflows during scrolling. Re-measure heading offsets whenever
|
|
// article geometry changes — keeps the fallback branch accurate without
|
|
// forcing a layout read inside the IO callback itself.
|
|
articleObserver = new ResizeObserver(scheduleRemeasure)
|
|
articleObserver.observe(article)
|
|
}
|
|
|
|
const init = () => {
|
|
const items = collectHeadings()
|
|
if (items && items.length > 0) {
|
|
currentItems = items
|
|
measureOffsets(items)
|
|
setupObserver(items)
|
|
observeArticleResize()
|
|
}
|
|
}
|
|
|
|
init()
|
|
window.addEventListener("load", remeasure)
|
|
document.addEventListener("astro:page-load", init)
|
|
|
|
const onResize = () => {
|
|
if (resizeRaf) cancelAnimationFrame(resizeRaf)
|
|
resizeRaf = requestAnimationFrame(remeasure)
|
|
}
|
|
window.addEventListener("resize", onResize, { passive: true })
|
|
|
|
const handleUserIntent = () => {
|
|
if (programmaticLockRef.current) {
|
|
unlockProgrammaticLock()
|
|
}
|
|
}
|
|
|
|
// `scrollend` (Chrome 114+, Safari 18+, Firefox 109+) is the cleanest
|
|
// signal that a programmatic smooth scroll has finished. Older browsers
|
|
// fall back to the existing 2500ms hard timer + user-gesture unlock.
|
|
const onScrollEnd = () => {
|
|
if (programmaticLockRef.current) unlockProgrammaticLock()
|
|
}
|
|
|
|
window.addEventListener("wheel", handleUserIntent, { passive: true })
|
|
window.addEventListener("touchstart", handleUserIntent, { passive: true })
|
|
window.addEventListener("keydown", handleUserIntent)
|
|
window.addEventListener("scrollend", onScrollEnd, { passive: true })
|
|
|
|
return () => {
|
|
observerRef.current?.disconnect()
|
|
articleObserver?.disconnect()
|
|
window.removeEventListener("load", remeasure)
|
|
document.removeEventListener("astro:page-load", init)
|
|
window.removeEventListener("resize", onResize)
|
|
window.removeEventListener("wheel", handleUserIntent)
|
|
window.removeEventListener("touchstart", handleUserIntent)
|
|
window.removeEventListener("keydown", handleUserIntent)
|
|
window.removeEventListener("scrollend", onScrollEnd)
|
|
if (resizeRaf) cancelAnimationFrame(resizeRaf)
|
|
if (remeasureRaf) cancelAnimationFrame(remeasureRaf)
|
|
clearTocAutoScrollTimer()
|
|
unlockProgrammaticLock()
|
|
}
|
|
}, [clearTocAutoScrollTimer, collectHeadings, measureOffsets, setupObserver, unlockProgrammaticLock])
|
|
|
|
useEffect(() => {
|
|
if (!activeId || !navRef.current) return
|
|
|
|
// Mobile renders the TOC inside a drawer that's usually closed; auto-scroll
|
|
// there forces extra layout reads on every heading change while the user
|
|
// scrolls the article. Desktop (sidebar) is the only place where keeping
|
|
// the active link visible matters.
|
|
if (typeof window !== "undefined" && window.matchMedia("(max-width: 1279px)").matches) {
|
|
return
|
|
}
|
|
|
|
clearTocAutoScrollTimer()
|
|
tocAutoScrollTimerRef.current = window.setTimeout(() => {
|
|
const nav = navRef.current
|
|
if (!nav) return
|
|
|
|
const activeLink = nav.querySelector<HTMLAnchorElement>(`a[href="#${CSS.escape(activeId)}"]`)
|
|
if (!activeLink) return
|
|
|
|
const scrollContainer = nav.closest<HTMLElement>(".toc-scroll-container")
|
|
if (!scrollContainer) return
|
|
|
|
const containerRect = scrollContainer.getBoundingClientRect()
|
|
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
|
|
const isContainerVisible = containerRect.bottom > 0 && containerRect.top < viewportHeight
|
|
if (!isContainerVisible) return
|
|
|
|
const linkRect = activeLink.getBoundingClientRect()
|
|
const padding = 12
|
|
const isOutOfView =
|
|
linkRect.top < containerRect.top + padding ||
|
|
linkRect.bottom > containerRect.bottom - padding
|
|
|
|
if (isOutOfView) {
|
|
activeLink.scrollIntoView({ block: "nearest", inline: "nearest" })
|
|
}
|
|
}, 90)
|
|
|
|
return () => {
|
|
clearTocAutoScrollTimer()
|
|
}
|
|
}, [activeId, clearTocAutoScrollTimer])
|
|
|
|
const handleClick = (e: React.MouseEvent, id: string) => {
|
|
e.preventDefault()
|
|
const el = document.getElementById(id)
|
|
if (!el) return
|
|
|
|
unlockProgrammaticLock()
|
|
programmaticLockRef.current = true
|
|
programmaticTargetRef.current = id
|
|
|
|
const top = el.getBoundingClientRect().top + window.scrollY - 80
|
|
window.scrollTo({ top, behavior: "smooth" })
|
|
setActiveId(id)
|
|
|
|
lockTimerRef.current = window.setTimeout(() => {
|
|
unlockProgrammaticLock()
|
|
}, 2500)
|
|
}
|
|
|
|
// 注意:这里不能 `return null`。
|
|
//
|
|
// Astro 靠「渲染器探测」决定交给哪个框架渲染:@astrojs/react 的 check 会试渲染一次,
|
|
// 拿到 null 就认不出这是 React 组件;接着轮到 @astrojs/mdx 的 check,而它是把组件
|
|
// 当普通函数**裸调**(不在 React 渲染树里),useState 找不到 dispatcher,于是每次
|
|
// SSR 都往控制台打一条 "Invalid hook call"。页面本身没错,但那行日志很误导人。
|
|
//
|
|
// 始终返回 <nav>,没有标题时用 hidden 收起来 —— 视觉结果和 return null 一样。
|
|
return (
|
|
<nav
|
|
ref={navRef}
|
|
aria-label="目录"
|
|
className={cn("toc-nav", headings.length === 0 && "hidden")}
|
|
>
|
|
{showHeader ? (
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<div className="w-1 h-4 bg-primary rounded-full" />
|
|
<span className="text-xs font-medium text-foreground tracking-wide">目录</span>
|
|
</div>
|
|
) : null}
|
|
<ul className="space-y-0.5">
|
|
{headings.map((heading) => (
|
|
<li key={heading.id}>
|
|
<a
|
|
href={`#${heading.id}`}
|
|
onClick={(e) => handleClick(e, heading.id)}
|
|
className={cn(
|
|
"block py-1 text-[13px] leading-relaxed border-l-2 transition-all duration-200",
|
|
heading.level === 2 ? "pl-3" : "pl-6",
|
|
activeId === heading.id
|
|
? "border-l-primary text-primary font-medium"
|
|
: "border-l-transparent text-muted-foreground hover:text-foreground hover:border-l-border",
|
|
)}
|
|
>
|
|
<span className="line-clamp-2">{heading.text}</span>
|
|
</a>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</nav>
|
|
)
|
|
}
|