feat(ssr): 真 SSR 收尾——干净 hydration 与浏览器原生滚动恢复

- 修复 onRenderClient 引入 serverClient 导致浏览器 process is not defined:
  抽出双端安全的 ssr/feedSeed.ts 作为唯一数据播种入口,serverClient 加 SSR 守卫
- 修复 React 19 Fizz Suspense Outlining:Suspense 边界从布局 Outlet 下移到
  各懒加载路由,SSR 输出不再出现 <!--$?--> / $RB / template 探针标记
- 修复窄屏 React #418 hydration 致命错误:useMediaQuery 首帧恒 false,
  useLayoutEffect 绘制前同步真实值;主题改 cookie 存储供 SSR 直出,
  head 内联防闪烁/缩放脚本移除(缩放锁改为客户端 zoomGuard 模块)
- 清理假 SSR 残留:删除 StaticFeedList 镜像组件、SSR 图标生成脚本、
  探针与死代码;列表接口裁剪正文,仅下发 excerpt/first_image
- Feed 与帖子详情改为窗口滚动(app-shell--doc-scroll):删除虚拟列表、
  自研滚动快照与全局滚轮 hack;F5/前进后退滚动位置全部交给浏览器原生
  历史恢复,HTML 响应头 no-cache 配合;锚点/楼层用 scrollIntoView +
  scroll-margin-top 适配 sticky 顶栏
This commit is contained in:
2026-09-09 20:53:28 +08:00
parent 3702e3d1f2
commit 2392768868
39 changed files with 468 additions and 1366 deletions

View File

@@ -44,13 +44,6 @@ export function clearAuthSeed() {
seededUnread = 0;
}
/** @deprecated 改用 peekAuthSeed + clearAuthSeed;保留兼容 */
export function takeAuthSeed(): AuthSeed {
const s = peekAuthSeed();
if (s.hasSeed) clearAuthSeed();
return s;
}
export function getBootUnread(): number {
return bootUnread;
}

View File

@@ -1,11 +1,5 @@
import type { NavigateFunction } from 'react-router';
import type { PostItem } from '../api/types';
import type { FeedSort } from '../components/FeedSortBar';
import {
feedCacheKey,
getHomeStoreState,
type FeedCacheEntry,
} from '../store/homeStore';
import { getHomeStoreState } from '../store/homeStore';
import { clearSessionSnapshots } from './sessionPageCache';
import { softRefreshCurrentPage } from './softRefresh';
import { transitionTo } from './spaTransition';
@@ -13,51 +7,6 @@ import { transitionTo } from './spaTransition';
/** 导航到帖子列表时附带的状态,用于同 URL 重复点击时强制刷新 */
export type FeedNavState = { refreshFeed?: boolean };
/** 对外暴露的缓存形状(兼容旧调用方;不含 lastFetchTime) */
export type FeedCache = {
posts: PostItem[];
postTotal: number;
page: number;
scrollTop: number;
};
function toPublic(entry: FeedCacheEntry): FeedCache {
return {
posts: entry.posts,
postTotal: entry.postTotal,
page: entry.page,
scrollTop: entry.scrollTop,
};
}
/** 读取帖子列表缓存(从详情页返回时恢复浏览位置) */
export function getFeedCache(
boardId: number,
keyword: string,
sort: FeedSort,
tag = '',
author = '',
titleOnly = false,
): FeedCache | null {
const key = feedCacheKey({ boardId, keyword, sort, tag, author, titleOnly });
const entry = getHomeStoreState().getFeed(key);
return entry ? toPublic(entry) : null;
}
/** 保存帖子列表缓存(写入 Zustand,并记录拉取时间) */
export function setFeedCache(
boardId: number,
keyword: string,
sort: FeedSort,
data: FeedCache,
tag = '',
author = '',
titleOnly = false,
) {
const key = feedCacheKey({ boardId, keyword, sort, tag, author, titleOnly });
getHomeStoreState().setFeed(key, data);
}
/** 清除所有帖子列表缓存(手动刷新 / 帖子变更时连详情快照一起作废) */
export function clearAllFeedCache() {
getHomeStoreState().clearAll();

View File

@@ -96,7 +96,6 @@ export function seedHomeBoot(boot: HomeBootPayload): HomeBootPayload {
posts,
postTotal: boot.post_total ?? posts.length,
page: boot.page || 1,
scrollTop: 0,
lastFetchTime: Date.now(),
});

View File

@@ -2,44 +2,19 @@
import { getScope } from '../ssr/scope';
declare global {
interface Window {
__J13_HYDRATING_HOME__?: boolean;
}
}
let hydrating = false;
export function beginHomeHydrate() {
hydrating = true;
try {
window.__J13_HYDRATING_HOME__ = true;
} catch {
/* ignore */
}
}
export function endHomeHydrate() {
hydrating = false;
try {
delete window.__J13_HYDRATING_HOME__;
} catch {
try {
window.__J13_HYDRATING_HOME__ = undefined;
} catch {
/* ignore */
}
}
}
/** 首帧是否必须与 SSR DOM 同构(SSR 渲染期读请求作用域,客户端读模块/窗口标志) */
/** 首帧是否必须与 SSR DOM 同构(SSR 渲染期读请求作用域,客户端读模块标志) */
export function isHomeHydrating(): boolean {
const scope = getScope();
if (scope) return scope.hydrating === true;
if (hydrating) return true;
try {
return !!window.__J13_HYDRATING_HOME__;
} catch {
return false;
}
return hydrating;
}

View File

@@ -113,7 +113,6 @@ async function prefetchFeed(url: URL, force: boolean): Promise<void> {
posts: Array.isArray(data.posts) ? data.posts : [],
postTotal: data.total ?? 0,
page: 1,
scrollTop: 0,
lastFetchTime: Date.now(),
});
}

View File

@@ -1,18 +1,32 @@
export type Theme = 'light' | 'dark';
const STORAGE_KEY = 'j13-theme';
const THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 年
/** 在 React 渲染前同步应用主题,避免首屏主题闪烁 */
/** 在 React 渲染前同步读取主题,避免首屏主题闪烁 */
export function getStoredTheme(): Theme {
if (typeof window === 'undefined') return 'light';
const stored = localStorage.getItem(STORAGE_KEY);
return stored === 'dark' ? 'dark' : 'light';
}
/**
* SSR 时从请求 Cookie 解析主题(onRenderHtml 用)。
* 主题同时写 localStorage 与 cookie:服务端读 cookie 直接渲染 <html class="dark">,
* 浏览器无需内联脚本即可在首帧拿到正确主题。
*/
export function readThemeFromCookie(cookieHeader: string | null | undefined): Theme {
if (!cookieHeader) return 'light';
const match = /(?:^|;\s*)j13-theme=(dark|light)(?:;|$)/.exec(cookieHeader);
return match?.[1] === 'dark' ? 'dark' : 'light';
}
export function applyTheme(theme: Theme) {
if (typeof document === 'undefined') return;
const root = document.documentElement;
root.classList.toggle('dark', theme === 'dark');
root.style.colorScheme = theme;
localStorage.setItem(STORAGE_KEY, theme);
// 同步 cookie 供下次 SSR 读取(path=/ 全站生效)
document.cookie = `${STORAGE_KEY}=${theme};path=/;max-age=${THEME_COOKIE_MAX_AGE};SameSite=Lax`;
}

View File

@@ -0,0 +1,33 @@
/**
* 手机/平板双指与手势缩放锁(头像裁剪、图片灯箱区域放行 pinch)。
*
* 只监听用户手势事件,而手势必然发生在页面加载与客户端 bundle 执行之后,
* 因此无需像主题脚本那样内联在 <head> 中,在客户端入口安装即可。
*/
export function installZoomGuard(): void {
if (typeof document === 'undefined') return;
const inZoomAllowArea = (target: EventTarget | null): boolean =>
!!target
&& target instanceof Element
&& typeof target.closest === 'function'
&& !!target.closest('.avatar-crop-stage, .image-lightbox');
const blockGesture = (e: Event) => {
if (inZoomAllowArea(e.target)) return;
if (e.cancelable) e.preventDefault();
};
const blockMultiTouch = (e: TouchEvent) => {
if (e.touches && e.touches.length > 1) {
if (inZoomAllowArea(e.target)) return;
if (e.cancelable) e.preventDefault();
}
};
document.addEventListener('gesturestart', blockGesture, { passive: false });
document.addEventListener('gesturechange', blockGesture, { passive: false });
document.addEventListener('gestureend', blockGesture, { passive: false });
document.addEventListener('touchstart', blockMultiTouch, { passive: false });
document.addEventListener('touchmove', blockMultiTouch, { passive: false });
}