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:
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} />
|
||||
|
||||
Reference in New Issue
Block a user