fix(ssr): guard DOM/window access and client-only listeners

- Prevent server-side exceptions (Application error) on mobile after redirects
  - useOneShotSlowHint: guard document; use global setTimeout; conditionally attach visibilitychange
  - useConnectionFeedback: guard document.visibilityState; register/remove listeners only on client
  - usePageSetup: guard window before tracking referrer and parsing roomId
  - tracking: early return when window is undefined
  - docs(flows): add “SSR & DOM access guard (must-read)” checklist; renumber next section
This commit is contained in:
david_bai
2025-12-06 12:00:03 +08:00
parent 0d830114cd
commit dceaae8efa
5 changed files with 46 additions and 10 deletions
+22 -1
View File
@@ -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)背压与分片策略深度分析
### 发送侧双层缓冲架构
+12 -6
View File
@@ -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]);
}
+2
View File
@@ -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);
+1
View File
@@ -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");
+9 -3
View File
@@ -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;
}