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

@@ -3,9 +3,10 @@ import type { ComponentType } from 'react';
import { hydrateRoot, createRoot } from 'react-dom/client';
import type { PageContextClient } from 'vike/types';
import { applyTheme, getStoredTheme } from '../src/utils/theme';
import { installZoomGuard } from '../src/utils/zoomGuard';
import { beginHomeHydrate } from '../src/utils/homeHydrate';
import { reloadForStaleChunk } from '../src/utils/chunkLoad';
import { seedFeedData, type FeedData } from '../src/ssr/feedData';
import { seedFeedData, type FeedData } from '../src/ssr/feedSeed';
import { ensureColdBootReady, isMainLayoutPath } from '../src/utils/prefetchRoute';
export { onRenderClient };
@@ -40,6 +41,7 @@ function waitForStylesheets(): Promise<void> {
async function onRenderClient(pageContext: PageContextClient) {
applyTheme(getStoredTheme());
installZoomGuard();
// hydrate 前把 +data 结果 seed 进客户端缓存(与服务端渲染前 seed 同构,保证首帧一致)
if (pageContext.isHydration) {
seedFeedData((pageContext as { data?: FeedData }).data);
@@ -54,6 +56,16 @@ async function onRenderClient(pageContext: PageContextClient) {
const el = document.getElementById('root');
if (!el) return;
// 有 SSR 正文 → hydrate 接管同一 DOM(首帧数据已由 +data seed,无需冷启动预热);
// SPA 壳 / SSR 降级空壳 → createRoot 挂载,挂载前先冷启动预热,避免空白首帧
const hydrating = pageContext.isHydration && el.innerHTML.trim() !== '';
// 仅非 hydrate 路径需要冷启动:SSR 页 seed 已灌入缓存,冷启动只会重复请求并阻塞 hydrate
if (!hydrating && isMainLayoutPath(window.location.pathname)) {
const path = `${window.location.pathname}${window.location.search}`;
await ensureColdBootReady(path);
}
await waitForStylesheets();
try {
if (document.fonts?.ready) await document.fonts.ready;
@@ -61,13 +73,6 @@ async function onRenderClient(pageContext: PageContextClient) {
/* ignore */
}
const path = `${window.location.pathname}${window.location.search}`;
if (isMainLayoutPath(window.location.pathname)) {
await ensureColdBootReady(path);
}
// 有 SSR 正文 → hydrate 接管同一 DOM(首帧 hydrate 同构变体);SPA 壳 → 整树挂载
const hydrating = pageContext.isHydration && el.innerHTML.trim() !== '';
if (hydrating) beginHomeHydrate();
const Page = pageContext.Page as PageComponent;