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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
33
frontend/src/utils/zoomGuard.ts
Normal file
33
frontend/src/utils/zoomGuard.ts
Normal 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user