fix: 从正文同步派生帖子目录,避免预取缓存竞态导致空树
EOF Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,7 +1,6 @@
|
|||||||
import { useMemo, useCallback, useEffect, useState } from 'react';
|
import { useMemo, useCallback, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { renderPostContentHtml } from '../utils/postContent';
|
import { renderPostContentHtml } from '../utils/postContent';
|
||||||
import { extractHeadingsFromHtml, type PostHeading } from '../utils/postHeadings';
|
|
||||||
import { loginPath, registerPath } from '../utils/authRedirect';
|
import { loginPath, registerPath } from '../utils/authRedirect';
|
||||||
import { useForumLimits } from '../hooks/useForumLimits';
|
import { useForumLimits } from '../hooks/useForumLimits';
|
||||||
import { notify } from '@/lib/notify';
|
import { notify } from '@/lib/notify';
|
||||||
@@ -12,8 +11,6 @@ interface Props {
|
|||||||
html: string;
|
html: string;
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
/** 正文标题树变化时回调(用于侧栏目录) */
|
|
||||||
onHeadingsChange?: (headings: PostHeading[]) => void;
|
|
||||||
/** 点击「回复可见」门控的「去回复」 */
|
/** 点击「回复可见」门控的「去回复」 */
|
||||||
onRequestReply?: () => void;
|
onRequestReply?: () => void;
|
||||||
/** 积分解锁成功后刷新正文 */
|
/** 积分解锁成功后刷新正文 */
|
||||||
@@ -26,7 +23,6 @@ export default function PostContent({
|
|||||||
html,
|
html,
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
className = 'post-detail-content',
|
className = 'post-detail-content',
|
||||||
onHeadingsChange,
|
|
||||||
onRequestReply,
|
onRequestReply,
|
||||||
onUnlocked,
|
onUnlocked,
|
||||||
postId: postIdProp,
|
postId: postIdProp,
|
||||||
@@ -39,19 +35,12 @@ export default function PostContent({
|
|||||||
const [lightboxAlt, setLightboxAlt] = useState('');
|
const [lightboxAlt, setLightboxAlt] = useState('');
|
||||||
const [unlocking, setUnlocking] = useState(false);
|
const [unlocking, setUnlocking] = useState(false);
|
||||||
|
|
||||||
const prepared = useMemo(() => {
|
const preparedHtml = useMemo(
|
||||||
const rendered = renderPostContentHtml(html, isLoggedIn, {
|
() => renderPostContentHtml(html, isLoggedIn, {
|
||||||
openLinksInNewTab: limits.open_content_links_in_new_tab,
|
openLinksInNewTab: limits.open_content_links_in_new_tab,
|
||||||
});
|
}),
|
||||||
return {
|
[html, isLoggedIn, limits.open_content_links_in_new_tab],
|
||||||
html: rendered,
|
);
|
||||||
headings: extractHeadingsFromHtml(rendered),
|
|
||||||
};
|
|
||||||
}, [html, isLoggedIn, limits.open_content_links_in_new_tab]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onHeadingsChange?.(prepared.headings);
|
|
||||||
}, [prepared.headings, onHeadingsChange]);
|
|
||||||
|
|
||||||
const openLightbox = useCallback((img: HTMLImageElement) => {
|
const openLightbox = useCallback((img: HTMLImageElement) => {
|
||||||
const full = img.getAttribute('data-full') || img.currentSrc || img.src;
|
const full = img.getAttribute('data-full') || img.currentSrc || img.src;
|
||||||
@@ -167,7 +156,7 @@ export default function PostContent({
|
|||||||
className={className}
|
className={className}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
dangerouslySetInnerHTML={{ __html: prepared.html }}
|
dangerouslySetInnerHTML={{ __html: preparedHtml }}
|
||||||
/>
|
/>
|
||||||
<ImageLightbox
|
<ImageLightbox
|
||||||
src={lightboxSrc}
|
src={lightboxSrc}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
import { useParams, useNavigate, useOutletContext, useLocation, useNavigationType } from 'react-router-dom';
|
import { useParams, useNavigate, useOutletContext, useLocation, useNavigationType } from 'react-router-dom';
|
||||||
import { ArrowLeft, ThumbsUp, Star, Lock, MessageSquare, MessageSquareOff, Flag, MoreHorizontal } from 'lucide-react';
|
import { ArrowLeft, ThumbsUp, Star, Lock, MessageSquare, MessageSquareOff, Flag, MoreHorizontal } from 'lucide-react';
|
||||||
import FeaturedIcon from '@/components/FeaturedIcon';
|
import FeaturedIcon from '@/components/FeaturedIcon';
|
||||||
@@ -60,7 +60,8 @@ import { excerptFromHTML, firstImageFromHTML } from '../utils/seoText';
|
|||||||
import { canonicalRedirectPath, parsePermalinkID, postPath } from '../utils/permalink';
|
import { canonicalRedirectPath, parsePermalinkID, postPath } from '../utils/permalink';
|
||||||
import { useForumLimits } from '../hooks/useForumLimits';
|
import { useForumLimits } from '../hooks/useForumLimits';
|
||||||
import type { LayoutCtx } from '../layouts/MainLayout';
|
import type { LayoutCtx } from '../layouts/MainLayout';
|
||||||
import type { PostHeading } from '../utils/postHeadings';
|
import { extractHeadingsFromHtml } from '../utils/postHeadings';
|
||||||
|
import { renderPostContentHtml } from '../utils/postContent';
|
||||||
import { InFlowSiteFooter } from '../components/SiteFooter';
|
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||||||
import NotFoundPage from './NotFoundPage';
|
import NotFoundPage from './NotFoundPage';
|
||||||
|
|
||||||
@@ -132,7 +133,6 @@ export default function PostDetailPage() {
|
|||||||
const [deletingPost, setDeletingPost] = useState(false);
|
const [deletingPost, setDeletingPost] = useState(false);
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
const [composerOpen, setComposerOpen] = useState(false);
|
const [composerOpen, setComposerOpen] = useState(false);
|
||||||
const [headings, setHeadings] = useState<PostHeading[]>([]);
|
|
||||||
const [reportOpen, setReportOpen] = useState(false);
|
const [reportOpen, setReportOpen] = useState(false);
|
||||||
const [reportReason, setReportReason] = useState<ReportReason>('spam');
|
const [reportReason, setReportReason] = useState<ReportReason>('spam');
|
||||||
const [reportDetail, setReportDetail] = useState('');
|
const [reportDetail, setReportDetail] = useState('');
|
||||||
@@ -173,6 +173,15 @@ export default function PostDetailPage() {
|
|||||||
|
|
||||||
const brand = getCachedSiteBranding();
|
const brand = getCachedSiteBranding();
|
||||||
const postContent = post?.content ?? '';
|
const postContent = post?.content ?? '';
|
||||||
|
const isLoggedIn = !!user;
|
||||||
|
// 同步从正文派生目录,避免预取缓存命中后 setHeadings([]) 盖掉子组件上报且不再回调
|
||||||
|
const headings = useMemo(() => {
|
||||||
|
if (!postContent.trim()) return [];
|
||||||
|
const rendered = renderPostContentHtml(postContent, isLoggedIn, {
|
||||||
|
openLinksInNewTab: limits.open_content_links_in_new_tab,
|
||||||
|
});
|
||||||
|
return extractHeadingsFromHtml(rendered);
|
||||||
|
}, [postContent, isLoggedIn, limits.open_content_links_in_new_tab]);
|
||||||
const postSEO = post ? {
|
const postSEO = post ? {
|
||||||
title: post.title,
|
title: post.title,
|
||||||
description: excerptFromHTML(postContent),
|
description: excerptFromHTML(postContent),
|
||||||
@@ -196,10 +205,6 @@ export default function PostDetailPage() {
|
|||||||
} : null;
|
} : null;
|
||||||
usePageSEO(postSEO);
|
usePageSEO(postSEO);
|
||||||
|
|
||||||
const handleHeadingsChange = useCallback((next: PostHeading[]) => {
|
|
||||||
setHeadings(next);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading || !post) {
|
if (loading || !post) {
|
||||||
setPostOutline({ headings: [], scrollRoot: null, title: '文章目录' });
|
setPostOutline({ headings: [], scrollRoot: null, title: '文章目录' });
|
||||||
@@ -263,7 +268,6 @@ export default function PostDetailPage() {
|
|||||||
setReplyTo(null);
|
setReplyTo(null);
|
||||||
setEditingCommentId(null);
|
setEditingCommentId(null);
|
||||||
setComposerOpen(false);
|
setComposerOpen(false);
|
||||||
setHeadings([]);
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -272,7 +276,6 @@ export default function PostDetailPage() {
|
|||||||
setReplyTo(null);
|
setReplyTo(null);
|
||||||
setEditingCommentId(null);
|
setEditingCommentId(null);
|
||||||
setComposerOpen(false);
|
setComposerOpen(false);
|
||||||
setHeadings([]);
|
|
||||||
if (!keep) {
|
if (!keep) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setPost(null);
|
setPost(null);
|
||||||
@@ -1124,9 +1127,8 @@ export default function PostDetailPage() {
|
|||||||
|
|
||||||
<PostContent
|
<PostContent
|
||||||
html={post.content || ''}
|
html={post.content || ''}
|
||||||
isLoggedIn={!!user}
|
isLoggedIn={isLoggedIn}
|
||||||
postId={post.id}
|
postId={post.id}
|
||||||
onHeadingsChange={handleHeadingsChange}
|
|
||||||
onRequestReply={scrollToCommentBox}
|
onRequestReply={scrollToCommentBox}
|
||||||
onUnlocked={() => { void reloadPostContent(); }}
|
onUnlocked={() => { void reloadPostContent(); }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user