feat: SSR 安全防卫与缓存作用域化(branding/limits/pages/auth/session)
theme/useTheme 服务端防卫 window/document;branding/limits/pages 缓存读请求作用域、seed 只写作用域;homeBoot 拆出 seedHomeBoot 与帖子详情快照构建器供 prefetchRoute 复用;getHomeStoreState 改返回 state 修复命令式访问
This commit is contained in:
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { ForumLimitsPublic } from '../api/types';
|
import type { ForumLimitsPublic } from '../api/types';
|
||||||
import { DEFAULT_ASIDE_WIDGETS, DEFAULT_FEED_SORT_TABS } from '../api/types';
|
import { DEFAULT_ASIDE_WIDGETS, DEFAULT_FEED_SORT_TABS } from '../api/types';
|
||||||
|
import { getScope } from '../ssr/scope';
|
||||||
|
|
||||||
const DEFAULT_LIMITS: ForumLimitsPublic = {
|
const DEFAULT_LIMITS: ForumLimitsPublic = {
|
||||||
post_title_max: 128,
|
post_title_max: 128,
|
||||||
@@ -38,6 +39,11 @@ let inflight: Promise<ForumLimitsPublic> | null = null;
|
|||||||
let cacheEpoch = 0;
|
let cacheEpoch = 0;
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
/** 当前生效的论坛限制:SSR 读请求作用域,客户端读模块缓存 */
|
||||||
|
function currentLimits(): ForumLimitsPublic | null {
|
||||||
|
return getScope()?.limits ?? cached;
|
||||||
|
}
|
||||||
|
|
||||||
function fetchLimits(): Promise<ForumLimitsPublic> {
|
function fetchLimits(): Promise<ForumLimitsPublic> {
|
||||||
if (cached) return Promise.resolve(cached);
|
if (cached) return Promise.resolve(cached);
|
||||||
if (inflight) return inflight;
|
if (inflight) return inflight;
|
||||||
@@ -53,8 +59,9 @@ function fetchLimits(): Promise<ForumLimitsPublic> {
|
|||||||
|
|
||||||
/** 获取前台可见的论坛限制配置 */
|
/** 获取前台可见的论坛限制配置 */
|
||||||
export function useForumLimits() {
|
export function useForumLimits() {
|
||||||
const [limits, setLimits] = useState<ForumLimitsPublic>(cached ?? DEFAULT_LIMITS);
|
const [limits, setLimits] = useState<ForumLimitsPublic>(currentLimits() ?? DEFAULT_LIMITS);
|
||||||
const [loading, setLoading] = useState(!cached);
|
const [loading, setLoading] = useState(!currentLimits());
|
||||||
|
|
||||||
const [epoch, setEpoch] = useState(cacheEpoch);
|
const [epoch, setEpoch] = useState(cacheEpoch);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -94,8 +101,13 @@ export async function ensureForumLimitsLoaded(opts?: { force?: boolean }): Promi
|
|||||||
return limits;
|
return limits;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 文档 SSR / 管理端:同步写入 limits 模块缓存 */
|
/** 文档 SSR / 管理端:同步写入 limits 模块缓存(SSR 时只写请求作用域) */
|
||||||
export function seedForumLimitsCache(limits: ForumLimitsPublic) {
|
export function seedForumLimitsCache(limits: ForumLimitsPublic) {
|
||||||
|
const scope = getScope();
|
||||||
|
if (scope) {
|
||||||
|
scope.limits = limits;
|
||||||
|
return;
|
||||||
|
}
|
||||||
cached = limits;
|
cached = limits;
|
||||||
cacheEpoch += 1;
|
cacheEpoch += 1;
|
||||||
listeners.forEach(fn => fn());
|
listeners.forEach(fn => fn());
|
||||||
@@ -110,5 +122,5 @@ export function invalidateForumLimitsCache() {
|
|||||||
|
|
||||||
/** 同步读取已缓存的论坛限制(供路径生成等非 hook 场景) */
|
/** 同步读取已缓存的论坛限制(供路径生成等非 hook 场景) */
|
||||||
export function getCachedForumLimits(): ForumLimitsPublic {
|
export function getCachedForumLimits(): ForumLimitsPublic {
|
||||||
return cached ?? DEFAULT_LIMITS;
|
return currentLimits() ?? DEFAULT_LIMITS;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { SiteBranding } from '../api/types';
|
import type { SiteBranding } from '../api/types';
|
||||||
|
import { getScope } from '../ssr/scope';
|
||||||
|
|
||||||
export const DEFAULT_BRANDING: SiteBranding = {
|
export const DEFAULT_BRANDING: SiteBranding = {
|
||||||
name: '姜十三论坛',
|
name: '姜十三论坛',
|
||||||
@@ -30,8 +31,8 @@ export function siteMetaDescription(brand: SiteBranding): string {
|
|||||||
return brand.slogan?.trim() ?? '';
|
return brand.slogan?.trim() ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从服务端注入的 boot 数据同步初始化,避免首屏闪默认站名 */
|
|
||||||
function readBootBranding(): SiteBranding | null {
|
function readBootBranding(): SiteBranding | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
try {
|
try {
|
||||||
const boot = window.__J13_BRANDING__;
|
const boot = window.__J13_BRANDING__;
|
||||||
if (!boot || typeof boot !== 'object') return null;
|
if (!boot || typeof boot !== 'object') return null;
|
||||||
@@ -50,6 +51,11 @@ let cacheEpoch = 0;
|
|||||||
let preferCacheOnce = false;
|
let preferCacheOnce = false;
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
/** 当前生效的品牌配置:SSR 读请求作用域,客户端读模块缓存 */
|
||||||
|
function currentBranding(): SiteBranding | null {
|
||||||
|
return getScope()?.branding ?? cached;
|
||||||
|
}
|
||||||
|
|
||||||
function fetchBranding(): Promise<SiteBranding> {
|
function fetchBranding(): Promise<SiteBranding> {
|
||||||
if (preferCacheOnce && cached) {
|
if (preferCacheOnce && cached) {
|
||||||
preferCacheOnce = false;
|
preferCacheOnce = false;
|
||||||
@@ -96,13 +102,13 @@ function applyDocumentBrand(brand: SiteBranding) {
|
|||||||
|
|
||||||
/** 同步读取已缓存的品牌配置(供 SEO 等非 hook 场景) */
|
/** 同步读取已缓存的品牌配置(供 SEO 等非 hook 场景) */
|
||||||
export function getCachedSiteBranding(): SiteBranding {
|
export function getCachedSiteBranding(): SiteBranding {
|
||||||
return cached ?? DEFAULT_BRANDING;
|
return currentBranding() ?? DEFAULT_BRANDING;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取站点品牌配置(名称、Logo 等) */
|
/** 获取站点品牌配置(名称、Logo 等) */
|
||||||
export function useSiteBranding() {
|
export function useSiteBranding() {
|
||||||
const [branding, setBranding] = useState<SiteBranding>(cached ?? DEFAULT_BRANDING);
|
const [branding, setBranding] = useState<SiteBranding>(currentBranding() ?? DEFAULT_BRANDING);
|
||||||
const [loading, setLoading] = useState(!cached);
|
const [loading, setLoading] = useState(!currentBranding());
|
||||||
const [epoch, setEpoch] = useState(cacheEpoch);
|
const [epoch, setEpoch] = useState(cacheEpoch);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -160,9 +166,15 @@ export function invalidateSiteBrandingCache() {
|
|||||||
listeners.forEach(fn => fn());
|
listeners.forEach(fn => fn());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 用管理端刚保存的值立即更新缓存与文档标题 */
|
/** 用管理端刚保存的值立即更新缓存与文档标题(SSR 时只写请求作用域) */
|
||||||
export function seedSiteBrandingCache(brand: SiteBranding) {
|
export function seedSiteBrandingCache(brand: SiteBranding) {
|
||||||
cached = { ...DEFAULT_BRANDING, ...brand };
|
const merged: SiteBranding = { ...DEFAULT_BRANDING, ...brand };
|
||||||
|
const scope = getScope();
|
||||||
|
if (scope) {
|
||||||
|
scope.branding = merged;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cached = merged;
|
||||||
applyDocumentBrand(cached);
|
applyDocumentBrand(cached);
|
||||||
cacheEpoch += 1;
|
cacheEpoch += 1;
|
||||||
listeners.forEach(fn => fn());
|
listeners.forEach(fn => fn());
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { SitePageSummary } from '../api/types';
|
import type { SitePageSummary } from '../api/types';
|
||||||
|
import { getScope } from '../ssr/scope';
|
||||||
import { PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
|
import { PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
|
||||||
import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
|
import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
|
||||||
|
|
||||||
let cache: SitePageSummary[] | null = null;
|
let cache: SitePageSummary[] | null = null;
|
||||||
let pending: Promise<SitePageSummary[]> | null = null;
|
let pending: Promise<SitePageSummary[]> | null = null;
|
||||||
|
|
||||||
|
/** 当前生效的站点页摘要:SSR 读请求作用域,客户端读模块缓存 */
|
||||||
|
function currentPages(): SitePageSummary[] | null {
|
||||||
|
return getScope()?.pages ?? cache;
|
||||||
|
}
|
||||||
|
|
||||||
/** 拉取或复用站点页摘要缓存(供冷启动门闩与 hook 共用) */
|
/** 拉取或复用站点页摘要缓存(供冷启动门闩与 hook 共用) */
|
||||||
export function ensureSitePagesLoaded(opts?: { force?: boolean }): Promise<SitePageSummary[]> {
|
export function ensureSitePagesLoaded(opts?: { force?: boolean }): Promise<SitePageSummary[]> {
|
||||||
|
const cur = currentPages();
|
||||||
if (opts?.force) {
|
if (opts?.force) {
|
||||||
cache = null;
|
cache = null;
|
||||||
pending = null;
|
pending = null;
|
||||||
}
|
}
|
||||||
if (cache) return Promise.resolve(cache);
|
if (cur) return Promise.resolve(cur);
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
pending = api.pages()
|
pending = api.pages()
|
||||||
.then(d => {
|
.then(d => {
|
||||||
@@ -31,7 +38,7 @@ export function ensureSitePagesLoaded(opts?: { force?: boolean }): Promise<SiteP
|
|||||||
|
|
||||||
/** 已发布单页摘要(页脚/侧栏导航) */
|
/** 已发布单页摘要(页脚/侧栏导航) */
|
||||||
export function useSitePages() {
|
export function useSitePages() {
|
||||||
const [pages, setPages] = useState<SitePageSummary[]>(cache ?? []);
|
const [pages, setPages] = useState<SitePageSummary[]>(currentPages() ?? []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = () => {
|
const load = () => {
|
||||||
@@ -68,8 +75,14 @@ export function invalidateSitePagesCache() {
|
|||||||
cache = null;
|
cache = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 文档 SSR:同步写入站点页摘要缓存 */
|
/** 文档 SSR:同步写入站点页摘要缓存(SSR 时只写请求作用域) */
|
||||||
export function seedSitePagesCache(pages: SitePageSummary[]) {
|
export function seedSitePagesCache(pages: SitePageSummary[]) {
|
||||||
cache = Array.isArray(pages) ? pages : [];
|
const list = Array.isArray(pages) ? pages : [];
|
||||||
|
const scope = getScope();
|
||||||
|
if (scope) {
|
||||||
|
scope.pages = list;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cache = list;
|
||||||
pending = null;
|
pending = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
export const useTheme = () => useContext(ThemeContext);
|
export const useTheme = () => useContext(ThemeContext);
|
||||||
|
|
||||||
export function useMediaQuery(query: string) {
|
export function useMediaQuery(query: string) {
|
||||||
const [match, setMatch] = useState(() => window.matchMedia(query).matches);
|
const [match, setMatch] = useState(() => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
return window.matchMedia(query).matches;
|
||||||
|
});
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const m = window.matchMedia(query);
|
const m = window.matchMedia(query);
|
||||||
const fn = () => setMatch(m.matches);
|
const fn = () => setMatch(m.matches);
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export function createHomeStore(): HomeStoreState {
|
|||||||
const clientStore: HomeStoreState = createHomeStore();
|
const clientStore: HomeStoreState = createHomeStore();
|
||||||
|
|
||||||
/** 非 React 路径(feedCache / homeBoot / prefetchRoute / SSR)统一入口:
|
/** 非 React 路径(feedCache / homeBoot / prefetchRoute / SSR)统一入口:
|
||||||
* SSR 请求期间返回请求级实例,客户端返回模块单例 */
|
* SSR 请求期间返回请求级实例状态,客户端返回模块单例状态 */
|
||||||
export function getHomeStoreState(): HomeStoreState {
|
export function getHomeStoreState(): HomeStoreApiState {
|
||||||
return getScope()?.homeStore ?? clientStore;
|
return (getScope()?.homeStore ?? clientStore).getState();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { User } from '../api/types';
|
import type { User } from '../api/types';
|
||||||
|
import { getScope } from '../ssr/scope';
|
||||||
|
|
||||||
/** 首页 SSR boot 注入的鉴权种子(consumeHomeBoot 写入;AuthProvider 仅 peek,保留至下次 boot) */
|
/** 首页 SSR boot 注入的鉴权种子(consumeHomeBoot 写入;AuthProvider 仅 peek,保留至下次 boot)
|
||||||
|
* SSR 时写入请求作用域,客户端为模块级单例 */
|
||||||
let seededUser: User | null | undefined;
|
let seededUser: User | null | undefined;
|
||||||
let seededUnread = 0;
|
let seededUnread = 0;
|
||||||
let hasAuthSeed = false;
|
let hasAuthSeed = false;
|
||||||
@@ -8,6 +10,11 @@ let hasAuthSeed = false;
|
|||||||
let bootUnread = 0;
|
let bootUnread = 0;
|
||||||
|
|
||||||
export function seedAuthFromHomeBoot(user: User | null | undefined, unread: number) {
|
export function seedAuthFromHomeBoot(user: User | null | undefined, unread: number) {
|
||||||
|
const scope = getScope();
|
||||||
|
if (scope) {
|
||||||
|
scope.auth = { hasSeed: true, user: user ?? null, unread: Math.max(0, unread | 0) };
|
||||||
|
return;
|
||||||
|
}
|
||||||
hasAuthSeed = true;
|
hasAuthSeed = true;
|
||||||
seededUser = user ?? null;
|
seededUser = user ?? null;
|
||||||
seededUnread = Math.max(0, unread | 0);
|
seededUnread = Math.max(0, unread | 0);
|
||||||
@@ -18,6 +25,12 @@ type AuthSeed = { hasSeed: boolean; user: User | null; unread: number };
|
|||||||
|
|
||||||
/** 只读种子,不清空(StrictMode 双挂 / useState initializer 可重复调用) */
|
/** 只读种子,不清空(StrictMode 双挂 / useState initializer 可重复调用) */
|
||||||
export function peekAuthSeed(): AuthSeed {
|
export function peekAuthSeed(): AuthSeed {
|
||||||
|
const scope = getScope();
|
||||||
|
if (scope) {
|
||||||
|
return scope.auth.hasSeed
|
||||||
|
? { hasSeed: true, user: scope.auth.user, unread: scope.auth.unread }
|
||||||
|
: { hasSeed: false, user: null, unread: 0 };
|
||||||
|
}
|
||||||
if (!hasAuthSeed) {
|
if (!hasAuthSeed) {
|
||||||
return { hasSeed: false, user: null, unread: bootUnread };
|
return { hasSeed: false, user: null, unread: bootUnread };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import type {
|
import type {
|
||||||
Board,
|
Board,
|
||||||
CheckInStatus,
|
CheckInStatus,
|
||||||
|
Comment,
|
||||||
CommunityShowcaseItem,
|
CommunityShowcaseItem,
|
||||||
ForumLimitsPublic,
|
ForumLimitsPublic,
|
||||||
ForumStats,
|
ForumStats,
|
||||||
|
PostDetailResponse,
|
||||||
PostItem,
|
PostItem,
|
||||||
|
PostLotteryView,
|
||||||
|
PollView,
|
||||||
RecentComment,
|
RecentComment,
|
||||||
RecentUser,
|
RecentUser,
|
||||||
SiteBranding,
|
SiteBranding,
|
||||||
@@ -26,6 +30,7 @@ import {
|
|||||||
import { setSessionSnapshot } from './sessionPageCache';
|
import { setSessionSnapshot } from './sessionPageCache';
|
||||||
import { seedAuthFromHomeBoot } from './authBoot';
|
import { seedAuthFromHomeBoot } from './authBoot';
|
||||||
import { checkInCacheKey } from '../hooks/useCheckIn';
|
import { checkInCacheKey } from '../hooks/useCheckIn';
|
||||||
|
import { isTimeDiffSignificant } from './content';
|
||||||
import type { FeedSort } from '../components/FeedSortBar';
|
import type { FeedSort } from '../components/FeedSortBar';
|
||||||
|
|
||||||
/** 与 Go homeBootPayload / window.__J13_HOME_BOOT__ 对齐 */
|
/** 与 Go homeBootPayload / window.__J13_HOME_BOOT__ 对齐 */
|
||||||
@@ -61,6 +66,7 @@ declare global {
|
|||||||
|
|
||||||
/** 读取并清除文档 SSR 注入的首页 boot,灌入各层缓存 */
|
/** 读取并清除文档 SSR 注入的首页 boot,灌入各层缓存 */
|
||||||
export function consumeHomeBoot(): HomeBootPayload | null {
|
export function consumeHomeBoot(): HomeBootPayload | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
const boot = window.__J13_HOME_BOOT__;
|
const boot = window.__J13_HOME_BOOT__;
|
||||||
try {
|
try {
|
||||||
delete window.__J13_HOME_BOOT__;
|
delete window.__J13_HOME_BOOT__;
|
||||||
@@ -68,7 +74,11 @@ export function consumeHomeBoot(): HomeBootPayload | null {
|
|||||||
window.__J13_HOME_BOOT__ = undefined;
|
window.__J13_HOME_BOOT__ = undefined;
|
||||||
}
|
}
|
||||||
if (!boot || typeof boot !== 'object') return null;
|
if (!boot || typeof boot !== 'object') return null;
|
||||||
|
return seedHomeBoot(boot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将 boot 数据灌入当前缓存层(SSR 时经请求作用域隔离;客户端为模块单例) */
|
||||||
|
export function seedHomeBoot(boot: HomeBootPayload): HomeBootPayload {
|
||||||
if (boot.limits) seedForumLimitsCache(boot.limits);
|
if (boot.limits) seedForumLimitsCache(boot.limits);
|
||||||
if (boot.branding) seedSiteBrandingCache(boot.branding);
|
if (boot.branding) seedSiteBrandingCache(boot.branding);
|
||||||
if (Array.isArray(boot.pages)) seedSitePagesCache(boot.pages);
|
if (Array.isArray(boot.pages)) seedSitePagesCache(boot.pages);
|
||||||
@@ -111,3 +121,46 @@ export function consumeHomeBoot(): HomeBootPayload | null {
|
|||||||
|
|
||||||
return boot;
|
return boot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 与 PostDetailPage 会话快照同形(prefetchRoute / SSR +data 共用) */
|
||||||
|
export type PostDetailSnapshot = {
|
||||||
|
post: PostItem;
|
||||||
|
comments: Comment[];
|
||||||
|
poll: PollView | null;
|
||||||
|
lottery: PostLotteryView | null;
|
||||||
|
liked: boolean;
|
||||||
|
favorited: boolean;
|
||||||
|
canEdit: boolean;
|
||||||
|
isEdited: boolean;
|
||||||
|
editBlockReason: string;
|
||||||
|
editWindowHours: number;
|
||||||
|
bountyCanRefund: boolean;
|
||||||
|
bountyRefundBlockReason: string;
|
||||||
|
bountyEligibleReplyCount: number;
|
||||||
|
scrollTop: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function postDetailCacheKey(id: number): string {
|
||||||
|
return `post:${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 由帖子详情 API 响应构建会话快照(客户端 prefetch 与服务端 +data 共用) */
|
||||||
|
export function buildPostDetailSnapshot(detail: PostDetailResponse, comments: Comment[]): PostDetailSnapshot {
|
||||||
|
return {
|
||||||
|
post: detail.post,
|
||||||
|
comments: Array.isArray(comments) ? comments : [],
|
||||||
|
poll: detail.poll ?? null,
|
||||||
|
lottery: detail.lottery ?? null,
|
||||||
|
liked: detail.liked,
|
||||||
|
favorited: detail.favorited,
|
||||||
|
canEdit: detail.can_edit ?? false,
|
||||||
|
isEdited: detail.is_edited
|
||||||
|
?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at),
|
||||||
|
editBlockReason: detail.edit_block_reason ?? '',
|
||||||
|
editWindowHours: detail.post_edit_window_hours ?? 0,
|
||||||
|
bountyCanRefund: detail.bounty_can_refund ?? true,
|
||||||
|
bountyRefundBlockReason: detail.bounty_refund_block_reason ?? '',
|
||||||
|
bountyEligibleReplyCount: detail.bounty_eligible_reply_count ?? 0,
|
||||||
|
scrollTop: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ import { ensureForumLimitsLoaded, getCachedForumLimits } from '../hooks/useForum
|
|||||||
import { ensureSiteBrandingLoaded } from '../hooks/useSiteBranding';
|
import { ensureSiteBrandingLoaded } from '../hooks/useSiteBranding';
|
||||||
import { ensureSitePagesLoaded } from '../hooks/useSitePages';
|
import { ensureSitePagesLoaded } from '../hooks/useSitePages';
|
||||||
import { feedCacheKey, getHomeStoreState } from '../store/homeStore';
|
import { feedCacheKey, getHomeStoreState } from '../store/homeStore';
|
||||||
|
import { buildPostDetailSnapshot, postDetailCacheKey } from './homeBoot';
|
||||||
import { resolveAsideWidgets } from './asideWidgets';
|
import { resolveAsideWidgets } from './asideWidgets';
|
||||||
import { isTimeDiffSignificant } from './content';
|
import { isChunkLoadError, reloadForStaleChunk } from './chunkLoad';
|
||||||
import { loadMyCommentIds } from './guest';
|
import { parsePermalinkID, parsePermalinkSlug } from './permalink';
|
||||||
|
import { getSessionSnapshot, setSessionSnapshot } from './sessionPageCache';
|
||||||
import {
|
import {
|
||||||
setCachedBoards,
|
setCachedBoards,
|
||||||
setCachedStats,
|
setCachedStats,
|
||||||
@@ -26,27 +28,7 @@ import {
|
|||||||
setCachedRecentUsers,
|
setCachedRecentUsers,
|
||||||
setCachedTags,
|
setCachedTags,
|
||||||
} from './layoutCache';
|
} from './layoutCache';
|
||||||
import { isChunkLoadError, reloadForStaleChunk } from './chunkLoad';
|
import { loadMyCommentIds } from './guest';
|
||||||
import { parsePermalinkID, parsePermalinkSlug } from './permalink';
|
|
||||||
import { getSessionSnapshot, setSessionSnapshot } from './sessionPageCache';
|
|
||||||
|
|
||||||
/** 与 PostDetailPage 会话快照同形,供预取写入 */
|
|
||||||
type PostDetailSnapshot = {
|
|
||||||
post: PostItem;
|
|
||||||
comments: Comment[];
|
|
||||||
poll: PollView | null;
|
|
||||||
lottery: PostLotteryView | null;
|
|
||||||
liked: boolean;
|
|
||||||
favorited: boolean;
|
|
||||||
canEdit: boolean;
|
|
||||||
isEdited: boolean;
|
|
||||||
editBlockReason: string;
|
|
||||||
editWindowHours: number;
|
|
||||||
bountyCanRefund: boolean;
|
|
||||||
bountyRefundBlockReason: string;
|
|
||||||
bountyEligibleReplyCount: number;
|
|
||||||
scrollTop: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function resolveUrl(to: To): URL {
|
function resolveUrl(to: To): URL {
|
||||||
if (typeof to === 'string') return new URL(to, window.location.origin);
|
if (typeof to === 'string') return new URL(to, window.location.origin);
|
||||||
@@ -137,7 +119,7 @@ async function prefetchFeed(url: URL, force: boolean): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function prefetchPost(id: number, force: boolean): Promise<void> {
|
async function prefetchPost(id: number, force: boolean): Promise<void> {
|
||||||
const key = `post:${id}`;
|
const key = postDetailCacheKey(id);
|
||||||
if (!force && getSessionSnapshot(key) !== undefined) return;
|
if (!force && getSessionSnapshot(key) !== undefined) return;
|
||||||
// force:保留旧快照直到新数据写回,避免软刷新中间态空白
|
// force:保留旧快照直到新数据写回,避免软刷新中间态空白
|
||||||
|
|
||||||
@@ -146,23 +128,7 @@ async function prefetchPost(id: number, force: boolean): Promise<void> {
|
|||||||
api.post(id),
|
api.post(id),
|
||||||
api.comments(id, myIds),
|
api.comments(id, myIds),
|
||||||
]);
|
]);
|
||||||
const snap: PostDetailSnapshot = {
|
const snap = buildPostDetailSnapshot(detail, comm.comments);
|
||||||
post: detail.post,
|
|
||||||
comments: Array.isArray(comm.comments) ? comm.comments : [],
|
|
||||||
poll: detail.poll ?? null,
|
|
||||||
lottery: detail.lottery ?? null,
|
|
||||||
liked: detail.liked,
|
|
||||||
favorited: detail.favorited,
|
|
||||||
canEdit: detail.can_edit ?? false,
|
|
||||||
isEdited: detail.is_edited
|
|
||||||
?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at),
|
|
||||||
editBlockReason: detail.edit_block_reason ?? '',
|
|
||||||
editWindowHours: detail.post_edit_window_hours ?? 0,
|
|
||||||
bountyCanRefund: detail.bounty_can_refund ?? true,
|
|
||||||
bountyRefundBlockReason: detail.bounty_refund_block_reason ?? '',
|
|
||||||
bountyEligibleReplyCount: detail.bounty_eligible_reply_count ?? 0,
|
|
||||||
scrollTop: 0,
|
|
||||||
};
|
|
||||||
setSessionSnapshot(key, snap);
|
setSessionSnapshot(key, snap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
/** 会话内页面快照:前进后退命中则不再请求,手动刷新或登录态变化时清空 */
|
/** 会话内页面快照:前进后退命中则不再请求,手动刷新或登录态变化时清空
|
||||||
|
* SSR 时经由请求作用域(scope.kv)隔离,客户端为模块级单例 */
|
||||||
|
import { getScope } from '../ssr/scope';
|
||||||
|
|
||||||
const MAX_ENTRIES = 48;
|
const MAX_ENTRIES = 48;
|
||||||
|
|
||||||
@@ -16,12 +18,19 @@ function touch(key: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getSessionSnapshot<T>(key: string): T | undefined {
|
export function getSessionSnapshot<T>(key: string): T | undefined {
|
||||||
|
const scope = getScope();
|
||||||
|
if (scope) return scope.kv.get(key) as T | undefined;
|
||||||
if (!store.has(key)) return undefined;
|
if (!store.has(key)) return undefined;
|
||||||
touch(key);
|
touch(key);
|
||||||
return store.get(key) as T;
|
return store.get(key) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setSessionSnapshot<T>(key: string, data: T): void {
|
export function setSessionSnapshot<T>(key: string, data: T): void {
|
||||||
|
const scope = getScope();
|
||||||
|
if (scope) {
|
||||||
|
scope.kv.set(key, data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
store.set(key, data);
|
store.set(key, data);
|
||||||
touch(key);
|
touch(key);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ const STORAGE_KEY = 'j13-theme';
|
|||||||
|
|
||||||
/** 在 React 渲染前同步应用主题,避免首屏主题闪烁 */
|
/** 在 React 渲染前同步应用主题,避免首屏主题闪烁 */
|
||||||
export function getStoredTheme(): Theme {
|
export function getStoredTheme(): Theme {
|
||||||
|
if (typeof window === 'undefined') return 'light';
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
return stored === 'dark' ? 'dark' : 'light';
|
return stored === 'dark' ? 'dark' : 'light';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyTheme(theme: Theme) {
|
export function applyTheme(theme: Theme) {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.classList.toggle('dark', theme === 'dark');
|
root.classList.toggle('dark', theme === 'dark');
|
||||||
root.style.colorScheme = theme;
|
root.style.colorScheme = theme;
|
||||||
|
|||||||
Reference in New Issue
Block a user