diff --git a/frontend/src/components/personal-report/personal-report-markdown-view.tsx b/frontend/src/components/personal-report/personal-report-markdown-view.tsx index d488a6a7..59c5cdff 100644 --- a/frontend/src/components/personal-report/personal-report-markdown-view.tsx +++ b/frontend/src/components/personal-report/personal-report-markdown-view.tsx @@ -1,6 +1,6 @@ "use client"; -import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from "react"; import { flushSync } from "react-dom"; import ReactMarkdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; @@ -104,17 +104,32 @@ function renderMarkdown(markdown: string, headings: readonly LongformHeading[]) ); } +/** + * 「到底部」 has to mount every lazy section before it scrolls. The flag lives + * outside React state so the article view itself stays state-free (its tree is + * memoized and must not re-render as a whole); only the sections subscribe. + */ +type RevealSignal = { revealed: boolean; listeners: Set<() => void> }; + +function subscribeReveal(signal: RevealSignal) { + return (listener: () => void) => { + signal.listeners.add(listener); + return () => signal.listeners.delete(listener); + }; +} + const LazyMarkdownSection = memo(function LazyMarkdownSection({ section, - forceVisible, + reveal, }: { section: LongformSection; - /** 「到底部」 mounts every section at once so the jump lands on the real end. */ - forceVisible: boolean; + reveal: RevealSignal; }) { const ref = useRef(null); const [seen, setVisible] = useState(section.eager); - const visible = seen || forceVisible; + const subscribe = useMemo(() => subscribeReveal(reveal), [reveal]); + const forced = useSyncExternalStore(subscribe, () => reveal.revealed, () => false); + const visible = seen || forced; useEffect(() => { if (visible) return; if (typeof IntersectionObserver === "undefined") { @@ -176,10 +191,13 @@ function ReportToc({ headings }: { headings: readonly LongformHeading[] }) { export function PersonalReportMarkdownView({ markdown }: { markdown: string }) { const outline = useMemo(() => buildLongformOutline(markdown), [markdown]); - const [revealed, setRevealed] = useState(false); + const reveal = useMemo(() => ({ revealed: false, listeners: new Set() }), []); // Synchronous on purpose: the caller scrolls to scrollHeight right after, and // that height is only final once every lazy section has committed. - const revealAll = useCallback(() => flushSync(() => setRevealed(true)), []); + const revealAll = useCallback(() => flushSync(() => { + reveal.revealed = true; + for (const listener of reveal.listeners) listener(); + }), [reveal]); const lead = useMemo( () => (outline.leadMarkdown ? renderMarkdown(outline.leadMarkdown, outline.headings) : null), [outline], @@ -195,7 +213,7 @@ export function PersonalReportMarkdownView({ markdown }: { markdown: string }) { ) : null} {outline.sections.filter((section) => !section.eager).map((section) => ( - + ))}