diff --git a/docs/ai-playbook/flows.zh-CN.md b/docs/ai-playbook/flows.zh-CN.md index 041a88b..056dff7 100644 --- a/docs/ai-playbook/flows.zh-CN.md +++ b/docs/ai-playbook/flows.zh-CN.md @@ -259,6 +259,27 @@ Core Services (webrtcService) + Store (fileTransferStore) 8. **富文本安全处理**:useRichTextToPlainText 服务端渲染安全,客户端 DOM 转换处理块级元素 9. **站内导航不中断(同一标签页)**:依赖 `frontend/stores/fileTransferStore.ts`(Zustand 单例)与 `frontend/lib/webrtcService.ts`(服务单例)。App Router 页面切换不打断传输且保留已选择/已接收内容。注意不要在路由切换副作用中调用 `webrtcService.leaveRoom()` 或重置 Store;刷新/新标签不在保证范围内。 +## 7)SSR 与 DOM 访问防护(必读) + +为避免“服务端异常(Application error)”这类 SSR 侧报错,前端改动需遵循以下守卫清单: + +- 仅在客户端生命周期中访问 DOM/Navigator + - 将 `document/window/navigator` 的访问放入 `useEffect`、事件回调或显式的客户端组件(文件顶部包含 `"use client";`)。 + - 在回调内使用前加守卫:`typeof document !== 'undefined'`、`typeof window !== 'undefined'`、`typeof navigator !== 'undefined'`。 +- 定时器与可见性判断 + - 使用全局 `setTimeout`/`clearTimeout`,避免直接引用 `window.setTimeout`。 + - 监听可见性:注册/移除 `visibilitychange` 事件前先判断 `typeof document !== 'undefined'`。 +- 事件监听的清理 + - 所有 `addEventListener` 都应在 `useEffect` 返回函数中对称 `removeEventListener`,并在服务端(无 `document`)时跳过注册。 +- 单例与模块副作用(重要) + - 禁止在模块顶层初始化依赖浏览器环境的实例(如 Socket、WebRTC、WakeLock 等)。应在客户端首次需要时惰性创建(lazy-init)。 + +参考实现: + +- `frontend/utils/useOneShotSlowHint.ts`:在 `useEffect` 中对 `document` 做判空;定时器使用全局 `setTimeout`。 +- `frontend/hooks/useConnectionFeedback.ts`:读取 `document.visibilityState` 前判空;仅在客户端注册 `visibilitychange`。 +- `frontend/hooks/usePageSetup.ts`、`frontend/lib/tracking.ts`:读取 `window.location` 前判空。 + ### UI 连接反馈状态机(弱网/VPN 提示) - 入房阶段(join) @@ -331,7 +352,7 @@ Core Services (webrtcService) + Store (fileTransferStore) - **错误处理标准化**:统一的消息提示机制(putMessageInMs) - **国际化集成**:useLocale + getDictionary 提供多语言支持 -## 7)背压与分片策略深度分析 +## 8)背压与分片策略深度分析 ### 发送侧双层缓冲架构 diff --git a/frontend/hooks/useConnectionFeedback.ts b/frontend/hooks/useConnectionFeedback.ts index 835bc51..bbea6b6 100644 --- a/frontend/hooks/useConnectionFeedback.ts +++ b/frontend/hooks/useConnectionFeedback.ts @@ -87,7 +87,8 @@ export function useConnectionFeedback({ disarmRtcSlow(); } if (nowShare === "disconnected") { - const isForeground = document.visibilityState === "visible"; + const isForeground = + typeof document !== "undefined" && document.visibilityState === "visible"; if ((everShareRef.current || wasDiscShareRef.current) && isForeground) { const msg = messages.text.ClipboardApp.rtc_reconnecting; if (msg) putMessageInMs(msg, true, 4000); @@ -117,7 +118,8 @@ export function useConnectionFeedback({ disarmRtcSlow(); } if (nowRecv === "disconnected") { - const isForeground = document.visibilityState === "visible"; + const isForeground = + typeof document !== "undefined" && document.visibilityState === "visible"; if ((everRecvRef.current || wasDiscRecvRef.current) && isForeground) { const msg = messages.text.ClipboardApp.rtc_reconnecting; if (msg) putMessageInMs(msg, false, 4000); @@ -141,6 +143,7 @@ export function useConnectionFeedback({ useEffect(() => { if (!messages) return; const onVisibilityChange = () => { + if (typeof document === "undefined") return; if (document.visibilityState !== "visible") return; const nowShare = sharePhaseRef.current; @@ -164,9 +167,12 @@ export function useConnectionFeedback({ } }; - document.addEventListener("visibilitychange", onVisibilityChange); - return () => { - document.removeEventListener("visibilitychange", onVisibilityChange); - }; + if (typeof document !== "undefined") { + document.addEventListener("visibilitychange", onVisibilityChange); + return () => { + document.removeEventListener("visibilitychange", onVisibilityChange); + }; + } + return; }, [messages, putMessageInMs]); } diff --git a/frontend/hooks/usePageSetup.ts b/frontend/hooks/usePageSetup.ts index 44ad038..bf86909 100644 --- a/frontend/hooks/usePageSetup.ts +++ b/frontend/hooks/usePageSetup.ts @@ -38,6 +38,8 @@ export function usePageSetup({ // Track referrer and handle URL 'roomId' parameter useEffect(() => { + // Guard in SSR + if (typeof window === "undefined") return; trackReferrer(); // Call on component mount const urlParams = new URLSearchParams(window.location.search); diff --git a/frontend/lib/tracking.ts b/frontend/lib/tracking.ts index b87d5a8..02a79df 100644 --- a/frontend/lib/tracking.ts +++ b/frontend/lib/tracking.ts @@ -1,6 +1,7 @@ import { setTrack } from "@/app/config/api"; // The website tracks the source through ?ref=reddit..., here to get the source, for example https://yourdomain.com?ref=producthunt export const trackReferrer = async () => { + if (typeof window === "undefined") return; // Get URL parameters const urlParams = new URLSearchParams(window.location.search); let ref = urlParams.get("ref"); diff --git a/frontend/utils/useOneShotSlowHint.ts b/frontend/utils/useOneShotSlowHint.ts index ea11910..01a02ca 100644 --- a/frontend/utils/useOneShotSlowHint.ts +++ b/frontend/utils/useOneShotSlowHint.ts @@ -53,7 +53,12 @@ export function useOneShotSlowHint({ if (shownRef.current) return; const payload = getMessage(); if (!payload || !payload.text) return; - if (visibilityGate && typeof document !== "undefined") { + if (visibilityGate) { + if (typeof document === "undefined") { + // In SSR, defer showing until client becomes visible + pendingRef.current = true; + return; + } if (document.visibilityState !== "visible") { pendingRef.current = true; return; @@ -67,7 +72,8 @@ export function useOneShotSlowHint({ const arm = useCallback( (_key?: string) => { if (timerRef.current) return; // already armed - timerRef.current = window.setTimeout(() => { + // Use global setTimeout to avoid SSR window reference + timerRef.current = setTimeout(() => { fireIfEligible(); }, thresholdMs) as unknown as number; }, @@ -77,6 +83,7 @@ export function useOneShotSlowHint({ // Visibility change handling: if pending, try to fire once when visible useEffect(() => { if (!visibilityGate) return; + if (typeof document === "undefined") return; const handler = () => { if (document.visibilityState !== "visible") return; if (pendingRef.current && !shownRef.current) { @@ -92,4 +99,3 @@ export function useOneShotSlowHint({ return { arm, disarm, reset } as const; } -