diff --git a/frontend/src/hooks/useForumLimits.ts b/frontend/src/hooks/useForumLimits.ts index ed40b82..63397d3 100644 --- a/frontend/src/hooks/useForumLimits.ts +++ b/frontend/src/hooks/useForumLimits.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { api } from '../api/client'; import type { ForumLimitsPublic } from '../api/types'; import { DEFAULT_ASIDE_WIDGETS, DEFAULT_FEED_SORT_TABS } from '../api/types'; +import { getScope } from '../ssr/scope'; const DEFAULT_LIMITS: ForumLimitsPublic = { post_title_max: 128, @@ -38,6 +39,11 @@ let inflight: Promise | null = null; let cacheEpoch = 0; const listeners = new Set<() => void>(); +/** 当前生效的论坛限制:SSR 读请求作用域,客户端读模块缓存 */ +function currentLimits(): ForumLimitsPublic | null { + return getScope()?.limits ?? cached; +} + function fetchLimits(): Promise { if (cached) return Promise.resolve(cached); if (inflight) return inflight; @@ -53,8 +59,9 @@ function fetchLimits(): Promise { /** 获取前台可见的论坛限制配置 */ export function useForumLimits() { - const [limits, setLimits] = useState(cached ?? DEFAULT_LIMITS); - const [loading, setLoading] = useState(!cached); + const [limits, setLimits] = useState(currentLimits() ?? DEFAULT_LIMITS); + const [loading, setLoading] = useState(!currentLimits()); + const [epoch, setEpoch] = useState(cacheEpoch); useEffect(() => { @@ -94,8 +101,13 @@ export async function ensureForumLimitsLoaded(opts?: { force?: boolean }): Promi return limits; } -/** 文档 SSR / 管理端:同步写入 limits 模块缓存 */ +/** 文档 SSR / 管理端:同步写入 limits 模块缓存(SSR 时只写请求作用域) */ export function seedForumLimitsCache(limits: ForumLimitsPublic) { + const scope = getScope(); + if (scope) { + scope.limits = limits; + return; + } cached = limits; cacheEpoch += 1; listeners.forEach(fn => fn()); @@ -110,5 +122,5 @@ export function invalidateForumLimitsCache() { /** 同步读取已缓存的论坛限制(供路径生成等非 hook 场景) */ export function getCachedForumLimits(): ForumLimitsPublic { - return cached ?? DEFAULT_LIMITS; + return currentLimits() ?? DEFAULT_LIMITS; } diff --git a/frontend/src/hooks/useSiteBranding.ts b/frontend/src/hooks/useSiteBranding.ts index 2019138..1f57414 100644 --- a/frontend/src/hooks/useSiteBranding.ts +++ b/frontend/src/hooks/useSiteBranding.ts @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { api } from '../api/client'; import type { SiteBranding } from '../api/types'; +import { getScope } from '../ssr/scope'; export const DEFAULT_BRANDING: SiteBranding = { name: '姜十三论坛', @@ -30,8 +31,8 @@ export function siteMetaDescription(brand: SiteBranding): string { return brand.slogan?.trim() ?? ''; } -/** 从服务端注入的 boot 数据同步初始化,避免首屏闪默认站名 */ function readBootBranding(): SiteBranding | null { + if (typeof window === 'undefined') return null; try { const boot = window.__J13_BRANDING__; if (!boot || typeof boot !== 'object') return null; @@ -50,6 +51,11 @@ let cacheEpoch = 0; let preferCacheOnce = false; const listeners = new Set<() => void>(); +/** 当前生效的品牌配置:SSR 读请求作用域,客户端读模块缓存 */ +function currentBranding(): SiteBranding | null { + return getScope()?.branding ?? cached; +} + function fetchBranding(): Promise { if (preferCacheOnce && cached) { preferCacheOnce = false; @@ -96,13 +102,13 @@ function applyDocumentBrand(brand: SiteBranding) { /** 同步读取已缓存的品牌配置(供 SEO 等非 hook 场景) */ export function getCachedSiteBranding(): SiteBranding { - return cached ?? DEFAULT_BRANDING; + return currentBranding() ?? DEFAULT_BRANDING; } /** 获取站点品牌配置(名称、Logo 等) */ export function useSiteBranding() { - const [branding, setBranding] = useState(cached ?? DEFAULT_BRANDING); - const [loading, setLoading] = useState(!cached); + const [branding, setBranding] = useState(currentBranding() ?? DEFAULT_BRANDING); + const [loading, setLoading] = useState(!currentBranding()); const [epoch, setEpoch] = useState(cacheEpoch); useEffect(() => { @@ -160,9 +166,15 @@ export function invalidateSiteBrandingCache() { listeners.forEach(fn => fn()); } -/** 用管理端刚保存的值立即更新缓存与文档标题 */ +/** 用管理端刚保存的值立即更新缓存与文档标题(SSR 时只写请求作用域) */ 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); cacheEpoch += 1; listeners.forEach(fn => fn()); diff --git a/frontend/src/hooks/useSitePages.ts b/frontend/src/hooks/useSitePages.ts index ec343df..c88c76e 100644 --- a/frontend/src/hooks/useSitePages.ts +++ b/frontend/src/hooks/useSitePages.ts @@ -1,19 +1,26 @@ import { useEffect, useState } from 'react'; import { api } from '../api/client'; import type { SitePageSummary } from '../api/types'; +import { getScope } from '../ssr/scope'; import { PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache'; import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh'; let cache: SitePageSummary[] | null = null; let pending: Promise | null = null; +/** 当前生效的站点页摘要:SSR 读请求作用域,客户端读模块缓存 */ +function currentPages(): SitePageSummary[] | null { + return getScope()?.pages ?? cache; +} + /** 拉取或复用站点页摘要缓存(供冷启动门闩与 hook 共用) */ export function ensureSitePagesLoaded(opts?: { force?: boolean }): Promise { + const cur = currentPages(); if (opts?.force) { cache = null; pending = null; } - if (cache) return Promise.resolve(cache); + if (cur) return Promise.resolve(cur); if (!pending) { pending = api.pages() .then(d => { @@ -31,7 +38,7 @@ export function ensureSitePagesLoaded(opts?: { force?: boolean }): Promise(cache ?? []); + const [pages, setPages] = useState(currentPages() ?? []); useEffect(() => { const load = () => { @@ -68,8 +75,14 @@ export function invalidateSitePagesCache() { cache = null; } -/** 文档 SSR:同步写入站点页摘要缓存 */ +/** 文档 SSR:同步写入站点页摘要缓存(SSR 时只写请求作用域) */ 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; } diff --git a/frontend/src/hooks/useTheme.tsx b/frontend/src/hooks/useTheme.tsx index 5e7f6a3..79c9e8c 100644 --- a/frontend/src/hooks/useTheme.tsx +++ b/frontend/src/hooks/useTheme.tsx @@ -24,7 +24,10 @@ export function ThemeProvider({ children }: { children: ReactNode }) { export const useTheme = () => useContext(ThemeContext); 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(() => { const m = window.matchMedia(query); const fn = () => setMatch(m.matches); diff --git a/frontend/src/store/homeStore.ts b/frontend/src/store/homeStore.ts index 4ca2785..046c3e2 100644 --- a/frontend/src/store/homeStore.ts +++ b/frontend/src/store/homeStore.ts @@ -106,7 +106,7 @@ export function createHomeStore(): HomeStoreState { const clientStore: HomeStoreState = createHomeStore(); /** 非 React 路径(feedCache / homeBoot / prefetchRoute / SSR)统一入口: - * SSR 请求期间返回请求级实例,客户端返回模块单例 */ -export function getHomeStoreState(): HomeStoreState { - return getScope()?.homeStore ?? clientStore; + * SSR 请求期间返回请求级实例状态,客户端返回模块单例状态 */ +export function getHomeStoreState(): HomeStoreApiState { + return (getScope()?.homeStore ?? clientStore).getState(); } diff --git a/frontend/src/utils/authBoot.ts b/frontend/src/utils/authBoot.ts index 3bf0114..7155075 100644 --- a/frontend/src/utils/authBoot.ts +++ b/frontend/src/utils/authBoot.ts @@ -1,6 +1,8 @@ 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 seededUnread = 0; let hasAuthSeed = false; @@ -8,6 +10,11 @@ let hasAuthSeed = false; let bootUnread = 0; 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; seededUser = user ?? null; seededUnread = Math.max(0, unread | 0); @@ -18,6 +25,12 @@ type AuthSeed = { hasSeed: boolean; user: User | null; unread: number }; /** 只读种子,不清空(StrictMode 双挂 / useState initializer 可重复调用) */ 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) { return { hasSeed: false, user: null, unread: bootUnread }; } diff --git a/frontend/src/utils/homeBoot.ts b/frontend/src/utils/homeBoot.ts index e00f5f6..19fce32 100644 --- a/frontend/src/utils/homeBoot.ts +++ b/frontend/src/utils/homeBoot.ts @@ -1,10 +1,14 @@ import type { Board, CheckInStatus, + Comment, CommunityShowcaseItem, ForumLimitsPublic, ForumStats, + PostDetailResponse, PostItem, + PostLotteryView, + PollView, RecentComment, RecentUser, SiteBranding, @@ -26,6 +30,7 @@ import { import { setSessionSnapshot } from './sessionPageCache'; import { seedAuthFromHomeBoot } from './authBoot'; import { checkInCacheKey } from '../hooks/useCheckIn'; +import { isTimeDiffSignificant } from './content'; import type { FeedSort } from '../components/FeedSortBar'; /** 与 Go homeBootPayload / window.__J13_HOME_BOOT__ 对齐 */ @@ -61,6 +66,7 @@ declare global { /** 读取并清除文档 SSR 注入的首页 boot,灌入各层缓存 */ export function consumeHomeBoot(): HomeBootPayload | null { + if (typeof window === 'undefined') return null; const boot = window.__J13_HOME_BOOT__; try { delete window.__J13_HOME_BOOT__; @@ -68,7 +74,11 @@ export function consumeHomeBoot(): HomeBootPayload | null { window.__J13_HOME_BOOT__ = undefined; } 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.branding) seedSiteBrandingCache(boot.branding); if (Array.isArray(boot.pages)) seedSitePagesCache(boot.pages); @@ -111,3 +121,46 @@ export function consumeHomeBoot(): HomeBootPayload | null { 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, + }; +} diff --git a/frontend/src/utils/prefetchRoute.ts b/frontend/src/utils/prefetchRoute.ts index 31ec066..15b11be 100644 --- a/frontend/src/utils/prefetchRoute.ts +++ b/frontend/src/utils/prefetchRoute.ts @@ -16,9 +16,11 @@ import { ensureForumLimitsLoaded, getCachedForumLimits } from '../hooks/useForum import { ensureSiteBrandingLoaded } from '../hooks/useSiteBranding'; import { ensureSitePagesLoaded } from '../hooks/useSitePages'; import { feedCacheKey, getHomeStoreState } from '../store/homeStore'; +import { buildPostDetailSnapshot, postDetailCacheKey } from './homeBoot'; import { resolveAsideWidgets } from './asideWidgets'; -import { isTimeDiffSignificant } from './content'; -import { loadMyCommentIds } from './guest'; +import { isChunkLoadError, reloadForStaleChunk } from './chunkLoad'; +import { parsePermalinkID, parsePermalinkSlug } from './permalink'; +import { getSessionSnapshot, setSessionSnapshot } from './sessionPageCache'; import { setCachedBoards, setCachedStats, @@ -26,27 +28,7 @@ import { setCachedRecentUsers, setCachedTags, } from './layoutCache'; -import { isChunkLoadError, reloadForStaleChunk } from './chunkLoad'; -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; -}; +import { loadMyCommentIds } from './guest'; function resolveUrl(to: To): URL { if (typeof to === 'string') return new URL(to, window.location.origin); @@ -137,7 +119,7 @@ async function prefetchFeed(url: URL, force: boolean): Promise { } async function prefetchPost(id: number, force: boolean): Promise { - const key = `post:${id}`; + const key = postDetailCacheKey(id); if (!force && getSessionSnapshot(key) !== undefined) return; // force:保留旧快照直到新数据写回,避免软刷新中间态空白 @@ -146,23 +128,7 @@ async function prefetchPost(id: number, force: boolean): Promise { api.post(id), api.comments(id, myIds), ]); - const snap: PostDetailSnapshot = { - 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, - }; + const snap = buildPostDetailSnapshot(detail, comm.comments); setSessionSnapshot(key, snap); } diff --git a/frontend/src/utils/sessionPageCache.ts b/frontend/src/utils/sessionPageCache.ts index 5c5f810..07c6c42 100644 --- a/frontend/src/utils/sessionPageCache.ts +++ b/frontend/src/utils/sessionPageCache.ts @@ -1,4 +1,6 @@ -/** 会话内页面快照:前进后退命中则不再请求,手动刷新或登录态变化时清空 */ +/** 会话内页面快照:前进后退命中则不再请求,手动刷新或登录态变化时清空 + * SSR 时经由请求作用域(scope.kv)隔离,客户端为模块级单例 */ +import { getScope } from '../ssr/scope'; const MAX_ENTRIES = 48; @@ -16,12 +18,19 @@ function touch(key: string) { } export function getSessionSnapshot(key: string): T | undefined { + const scope = getScope(); + if (scope) return scope.kv.get(key) as T | undefined; if (!store.has(key)) return undefined; touch(key); return store.get(key) as T; } export function setSessionSnapshot(key: string, data: T): void { + const scope = getScope(); + if (scope) { + scope.kv.set(key, data); + return; + } store.set(key, data); touch(key); } diff --git a/frontend/src/utils/theme.ts b/frontend/src/utils/theme.ts index 08f3098..3635912 100644 --- a/frontend/src/utils/theme.ts +++ b/frontend/src/utils/theme.ts @@ -4,11 +4,13 @@ const STORAGE_KEY = 'j13-theme'; /** 在 React 渲染前同步应用主题,避免首屏主题闪烁 */ export function getStoredTheme(): Theme { + if (typeof window === 'undefined') return 'light'; const stored = localStorage.getItem(STORAGE_KEY); return stored === '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;