fix(reports): keep the article view state-free; reveal-all is an external signal (BUG-1094)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
Jesse_Chen
2026-09-29 19:04:45 +08:00
co-authored by Claude Opus 5.5
parent d85ab9a2d2
commit fe1c347f19
@@ -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<HTMLElement>(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<RevealSignal>(() => ({ 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 }) {
</section>
) : null}
{outline.sections.filter((section) => !section.eager).map((section) => (
<LazyMarkdownSection forceVisible={revealed} key={section.id} section={section} />
<LazyMarkdownSection key={section.id} reveal={reveal} section={section} />
))}
</article>
<ReportEdgeJump revealAll={revealAll} />