feat: 首页 Go SSR 与 React hydrate 同构,消壳层与帖行闪动

补齐侧栏/右栏图标与徽章、鉴权种子、StaticFeedList,并修正嵌套 a 与标题徽章对齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-01 07:07:38 +08:00
parent 9e134916a4
commit 44eed97fe0
26 changed files with 2530 additions and 159 deletions

View File

@@ -0,0 +1,43 @@
import type { User } from '../api/types';
/** 首页 SSR boot 注入的鉴权种子consumeHomeBoot 写入AuthProvider 仅 peek保留至下次 boot */
let seededUser: User | null | undefined;
let seededUnread = 0;
let hasAuthSeed = false;
/** 未读数可被 MainLayout 同步读取 */
let bootUnread = 0;
export function seedAuthFromHomeBoot(user: User | null | undefined, unread: number) {
hasAuthSeed = true;
seededUser = user ?? null;
seededUnread = Math.max(0, unread | 0);
bootUnread = seededUnread;
}
type AuthSeed = { hasSeed: boolean; user: User | null; unread: number };
/** 只读种子不清空StrictMode 双挂 / useState initializer 可重复调用) */
export function peekAuthSeed(): AuthSeed {
if (!hasAuthSeed) {
return { hasSeed: false, user: null, unread: bootUnread };
}
return { hasSeed: true, user: seededUser ?? null, unread: seededUnread };
}
/** mount 后清除用户种子,避免后续误用 */
export function clearAuthSeed() {
hasAuthSeed = false;
seededUser = undefined;
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

@@ -88,19 +88,18 @@ function isSameFeedUrl(url: string): boolean {
/**
* 导航到帖子列表。
* 默认:等待预热后再换页;同 URL 或 `refresh: true` 时静默软刷新(进度条)。
* 默认:等待预热后再换页;同 URL 或 `refresh: true` 时软刷新(带顶栏进度条)。
*/
export function navigateFeed(nav: NavigateFunction, url: string, opts?: { refresh?: boolean }) {
const same = isSameFeedUrl(url);
const refresh = opts?.refresh ?? same;
if (refresh) {
if (same) {
void softRefreshCurrentPage(url);
void softRefreshCurrentPage(url, { progress: true });
return;
}
void transitionTo(nav, url, {
force: true,
silent: true,
state: { refreshFeed: true } satisfies FeedNavState,
});
return;

View File

@@ -0,0 +1,110 @@
import type {
Board,
CheckInStatus,
CommunityShowcaseItem,
ForumLimitsPublic,
ForumStats,
PostItem,
RecentComment,
RecentUser,
SiteBranding,
SitePageSummary,
TagCount,
User,
} from '../api/types';
import { seedSiteBrandingCache } from '../hooks/useSiteBranding';
import { seedForumLimitsCache } from '../hooks/useForumLimits';
import { seedSitePagesCache } from '../hooks/useSitePages';
import { feedCacheKey, getHomeStoreState } from '../store/homeStore';
import {
setCachedBoards,
setCachedRecentComments,
setCachedRecentUsers,
setCachedStats,
setCachedTags,
} from './layoutCache';
import { setSessionSnapshot } from './sessionPageCache';
import { seedAuthFromHomeBoot } from './authBoot';
import { checkInCacheKey } from '../hooks/useCheckIn';
import type { FeedSort } from '../components/FeedSortBar';
/** 与 Go homeBootPayload / window.__J13_HOME_BOOT__ 对齐 */
export type HomeBootPayload = {
board_id: number;
sort: string;
keyword: string;
tag: string;
author: string;
title_only: boolean;
posts: PostItem[];
post_total: number;
page: number;
boards: Board[];
stats: ForumStats;
recent_comments: RecentComment[];
recent_users: RecentUser[];
tags: TagCount[];
showcase: CommunityShowcaseItem[];
pages: SitePageSummary[];
limits: ForumLimitsPublic;
branding: SiteBranding;
user?: User | null;
unread_messages?: number;
check_in?: CheckInStatus | null;
};
declare global {
interface Window {
__J13_HOME_BOOT__?: HomeBootPayload;
}
}
/** 读取并清除文档 SSR 注入的首页 boot灌入各层缓存 */
export function consumeHomeBoot(): HomeBootPayload | null {
const boot = window.__J13_HOME_BOOT__;
try {
delete window.__J13_HOME_BOOT__;
} catch {
window.__J13_HOME_BOOT__ = undefined;
}
if (!boot || typeof boot !== 'object') return null;
if (boot.limits) seedForumLimitsCache(boot.limits);
if (boot.branding) seedSiteBrandingCache(boot.branding);
if (Array.isArray(boot.pages)) seedSitePagesCache(boot.pages);
if (Array.isArray(boot.boards)) setCachedBoards(boot.boards);
if (boot.stats) setCachedStats(boot.stats);
if (Array.isArray(boot.recent_comments)) setCachedRecentComments(boot.recent_comments);
if (Array.isArray(boot.recent_users)) setCachedRecentUsers(boot.recent_users);
if (Array.isArray(boot.tags)) setCachedTags(boot.tags);
if (Array.isArray(boot.showcase)) setSessionSnapshot('showcase', boot.showcase);
// 鉴权 / 签到:有 user 字段即种子(含 null = 已确认访客)
if ('user' in boot) {
seedAuthFromHomeBoot(boot.user ?? null, boot.unread_messages ?? 0);
const uid = boot.user?.id;
if (uid && boot.check_in) {
setSessionSnapshot(checkInCacheKey(uid), boot.check_in);
}
}
const sort = (boot.sort || 'reply') as FeedSort;
const key = feedCacheKey({
boardId: boot.board_id || 0,
keyword: boot.keyword || '',
tag: boot.tag || '',
author: boot.author || '',
titleOnly: !!boot.title_only,
sort,
});
const posts = Array.isArray(boot.posts) ? boot.posts : [];
getHomeStoreState().setFeed(key, {
posts,
postTotal: boot.post_total ?? posts.length,
page: boot.page || 1,
scrollTop: 0,
lastFetchTime: Date.now(),
});
return boot;
}

View File

@@ -0,0 +1,41 @@
/** 首页 SSR hydrate 首帧同构标志(仅 / 与板块首页) */
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 */
}
}
}
/** 首帧是否必须与 Go SSR DOM 同构 */
export function isHomeHydrating(): boolean {
if (hydrating) return true;
try {
return !!window.__J13_HYDRATING_HOME__;
} catch {
return false;
}
}

View File

@@ -1,13 +1,21 @@
import { prefetchLayoutShell, prefetchRoute } from './prefetchRoute';
import { doneTransition, startTransition } from './spaTransition';
/** 软刷新齐套后的单一提交:各组件同一拍从 cache/快照同步 UI禁止分批闪烁 */
export const PAGE_SOFT_REFRESH_COMMIT_EVENT = 'page-soft-refresh-commit';
export type SoftRefreshOpts = {
/** 是否显示顶栏进度条Logo 刷新开;下拉关) */
progress?: boolean;
};
/**
* 静默刷新当前页:不改画面、无进度条,预热齐套后派发一次 commit。
* 刷新当前页:预热齐套后派发一次 commit。
* `progress: true` 时走顶栏进度条。
*/
export async function softRefreshCurrentPage(to?: string): Promise<void> {
export async function softRefreshCurrentPage(to?: string, opts?: SoftRefreshOpts): Promise<void> {
const path = to ?? `${window.location.pathname}${window.location.search}`;
const id = opts?.progress ? startTransition() : undefined;
try {
await Promise.all([
prefetchRoute(path, { force: true }),
@@ -17,4 +25,5 @@ export async function softRefreshCurrentPage(to?: string): Promise<void> {
// 仍派发 commit让界面有机会用已有缓存自愈
}
window.dispatchEvent(new Event(PAGE_SOFT_REFRESH_COMMIT_EVENT));
if (id != null) doneTransition(id);
}