mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
fix(frontend): render historical markdown without waiting for the bottom / 历史消息不等滚动到底即渲染 (#9570) (#9585)
* fix(frontend): render historical markdown without waiting for the bottom (#9570) MarkdownHistory deferred every parse commit until the reader scrolled back to the bottom (2px epsilon), so any answer read while parked above the bottom — after switching sessions back, or a natural session end — stayed raw plain text until a manual scroll to the end. The deferred handoff is now limited to the case it actually protects: a long answer (more than one tail window) whose fallback marker is inside the reader's viewport, where the tail-window swap would visibly remove blocks and yank the scroller. Short answers (full document fits the tail window) and off-screen rows commit as soon as the worker lands. - tests: mid-read fallback case now pins the marker inside the viewport; new off-screen long-answer case asserts immediate commit * fix(frontend): measure pending markdown rows in transcript viewport Problem: Historical Markdown could stay raw above the bottom, while long visible rows could swap unsafely because the visibility guard measured a display:none marker against the app window. Root cause: The hidden marker has an empty DOMRect in real browsers, and window.innerHeight is not the transcript viewport, so long rows were misclassified as offscreen. Fix: Measure the real Virtuoso transcript row against the transcript scroller. Commit short and offscreen histories immediately while conservatively deferring visible long rows to preserve the reader anchor. Add deterministic coverage for short, visible-long, and offscreen-long handoffs. Verification: - pnpm exec tsx src/__tests__/markdown-history.test.tsx - pnpm typecheck - pnpm check:scroll-writer - pnpm test:transcript - pnpm build - pnpm test:transcript-browser Co-authored-by: SivanCola <[email protected]> * fix(frontend): honor reachable transcript tail for markdown swap Problem: Long visible historical answers could remain on the plain-text fallback after the reader returned to the only native-reachable tail. Root cause: MarkdownHistory used a private raw two-pixel bottom check instead of the transcript geometry contract that records small native tail clamps. Fix: Reuse the shared native transcript distance and threshold, and cover an eight-pixel no-op native clamp in the deferred Markdown handoff test. Verification: focused Markdown test, typecheck, scroll-writer gate, transcript suite, and production build passed. --------- Co-authored-by: SivanCola <[email protected]>
This commit is contained in:
1 parent
60fbe500d5
commit
e7eb57adbc
2 files changed
+138
-9
No files matched your search
@@ -11,6 +11,10 @@ import { createRoot } from "react-dom/client";
|
||||
import MarkdownHistory from "../components/MarkdownHistory";
|
||||
import { TranscriptScrollWriteProvider } from "../components/TranscriptLayoutIntentContext";
|
||||
import { parseMarkdown, markdownContentRevision } from "../lib/markdownPipeline";
|
||||
import {
|
||||
nativeTranscriptDistanceFromBottom,
|
||||
observeNativeTranscriptTailClamp,
|
||||
} from "../lib/transcriptScrollGeometry";
|
||||
import {
|
||||
disposeMarkdownWorkerClient,
|
||||
MarkdownWorkerClient,
|
||||
@@ -208,6 +212,20 @@ console.log("\nmarkdown history rendering");
|
||||
rootEl.scrollTop = 400;
|
||||
Object.defineProperty(rootEl, "scrollHeight", { configurable: true, value: 1_000 });
|
||||
Object.defineProperty(rootEl, "clientHeight", { configurable: true, value: 300 });
|
||||
// The pending Virtuoso row sits inside the transcript viewport: the long answer
|
||||
// keeps the stable fallback because the tail-window swap would remove the
|
||||
// blocks being read (#9570 keeps that protection; short answers and
|
||||
// off-screen rows commit immediately now).
|
||||
const originalRectMidRead = dom.window.HTMLElement.prototype.getBoundingClientRect;
|
||||
dom.window.HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRect() {
|
||||
if (this.classList.contains("transcript")) {
|
||||
return { top: 50, bottom: 350, left: 0, right: 800, width: 800, height: 300, x: 0, y: 50, toJSON: () => ({}) };
|
||||
}
|
||||
if (this.classList.contains("transcript__row")) {
|
||||
return { top: 100, bottom: 400, left: 0, right: 0, width: 0, height: 300, x: 0, y: 100, toJSON: () => ({}) };
|
||||
}
|
||||
return { top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}) };
|
||||
};
|
||||
(globalThis as { Worker?: unknown }).Worker = class {};
|
||||
let resolveParse: ((result: ReturnType<typeof parseMarkdown>) => void) | null = null;
|
||||
const deferred = new MarkdownWorkerClient({
|
||||
@@ -227,7 +245,11 @@ console.log("\nmarkdown history rendering");
|
||||
const root5 = createRoot(rootEl);
|
||||
const text = Array.from({ length: 60 }, (_, index) => `# Anchor ${index + 1}\n\nBody ${index + 1}.`).join("\n\n");
|
||||
await act(async () => {
|
||||
root5.render(<MarkdownHistory text={text} entryId="md-history-mid-read" fallback={<div className="md">{text}</div>} />);
|
||||
root5.render(
|
||||
<div className="transcript__row">
|
||||
<MarkdownHistory text={text} entryId="md-history-mid-read" fallback={<div className="md">{text}</div>} />
|
||||
</div>,
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
resolveParse?.(parseMarkdown(text));
|
||||
@@ -236,7 +258,10 @@ console.log("\nmarkdown history rendering");
|
||||
ok(!rootEl.querySelector(".md[data-markdown-blocks]"), "worker completion keeps the full fallback while the reader is away from bottom");
|
||||
ok(rootEl.textContent?.includes("Anchor 1"), "the reader's prefix remains mounted during the deferred handoff");
|
||||
|
||||
rootEl.scrollTop = 700;
|
||||
rootEl.scrollTop = 692;
|
||||
ok(!observeNativeTranscriptTailClamp(rootEl, 692), "the first native no-op tail write remains pending");
|
||||
ok(observeNativeTranscriptTailClamp(rootEl, 692), "a repeated native no-op tail write confirms the reachable bottom");
|
||||
eq(nativeTranscriptDistanceFromBottom(rootEl), 0, "the shared transcript geometry treats the native clamp as bottom");
|
||||
await act(async () => {
|
||||
rootEl.dispatchEvent(new dom.window.Event("scroll"));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
@@ -244,6 +269,66 @@ console.log("\nmarkdown history rendering");
|
||||
ok(rootEl.querySelector('.md[data-markdown-blocks="120"]'), "returning to bottom commits the cached parsed blocks");
|
||||
ok(rootEl.textContent?.includes("Anchor 60"), "the parsed tail is visible after the safe handoff");
|
||||
await act(async () => root5.unmount());
|
||||
dom.window.HTMLElement.prototype.getBoundingClientRect = originalRectMidRead;
|
||||
rootEl.className = "";
|
||||
delete (globalThis as { Worker?: unknown }).Worker;
|
||||
setMarkdownWorkerClientForTest(newSpyClient());
|
||||
}
|
||||
|
||||
// ── a short visible answer renders even when the reader is not at bottom ──
|
||||
{
|
||||
rootEl.className = "transcript";
|
||||
rootEl.scrollTop = 400;
|
||||
Object.defineProperty(rootEl, "scrollHeight", { configurable: true, value: 1_000 });
|
||||
Object.defineProperty(rootEl, "clientHeight", { configurable: true, value: 300 });
|
||||
const root5a = createRoot(rootEl);
|
||||
const text = "# Short answer\n\nThis **must render** without a trip to the bottom.";
|
||||
await act(async () => {
|
||||
root5a.render(
|
||||
<div className="transcript__row">
|
||||
<MarkdownHistory text={text} entryId="md-history-short-visible" fallback={<div className="md">{text}</div>} />
|
||||
</div>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
ok(rootEl.querySelector('.md[data-markdown-blocks="2"]'), "a short visible answer commits while the reader remains above the bottom");
|
||||
ok(rootEl.querySelector(".md strong"), "the short answer exposes rendered markdown instead of the raw fallback");
|
||||
await act(async () => root5a.unmount());
|
||||
rootEl.className = "";
|
||||
}
|
||||
|
||||
// ── an off-screen long answer commits without waiting for the reader ─────────
|
||||
{
|
||||
rootEl.className = "transcript";
|
||||
rootEl.scrollTop = 400;
|
||||
Object.defineProperty(rootEl, "scrollHeight", { configurable: true, value: 50_000 });
|
||||
Object.defineProperty(rootEl, "clientHeight", { configurable: true, value: 300 });
|
||||
const originalRectOffscreen = dom.window.HTMLElement.prototype.getBoundingClientRect;
|
||||
dom.window.HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRect() {
|
||||
if (this.classList.contains("transcript")) {
|
||||
return { top: 50, bottom: 350, left: 0, right: 800, width: 800, height: 300, x: 0, y: 50, toJSON: () => ({}) };
|
||||
}
|
||||
if (this.classList.contains("transcript__row")) {
|
||||
return { top: 500, bottom: 800, left: 0, right: 800, width: 800, height: 300, x: 0, y: 500, toJSON: () => ({}) };
|
||||
}
|
||||
return { top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}) };
|
||||
};
|
||||
// A mounted overscan row outside the transcript viewport must render instead
|
||||
// of waiting for a bottom scroll that may never come (#9570).
|
||||
const root5b = createRoot(rootEl);
|
||||
const text = Array.from({ length: 90 }, (_, index) => `# Away ${index + 1}\n\nBody ${index + 1}.`).join("\n\n");
|
||||
await act(async () => {
|
||||
root5b.render(
|
||||
<div className="transcript__row">
|
||||
<MarkdownHistory text={text} entryId="md-history-offscreen" fallback={<div className="md">{text}</div>} />
|
||||
</div>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
ok(rootEl.querySelector('.md[data-markdown-blocks="180"]'), "the off-screen long answer renders immediately");
|
||||
ok(!rootEl.textContent?.includes("Away 1\n\nBody 1."), "the raw fallback is replaced by the bounded tail window");
|
||||
await act(async () => root5b.unmount());
|
||||
dom.window.HTMLElement.prototype.getBoundingClientRect = originalRectOffscreen;
|
||||
rootEl.className = "";
|
||||
delete (globalThis as { Worker?: unknown }).Worker;
|
||||
setMarkdownWorkerClientForTest(newSpyClient());
|
||||
@@ -253,6 +338,8 @@ console.log("\nmarkdown history rendering");
|
||||
{
|
||||
rootEl.className = "transcript";
|
||||
rootEl.scrollTop = 1_000;
|
||||
Object.defineProperty(rootEl, "scrollHeight", { configurable: true, value: 1_300 });
|
||||
Object.defineProperty(rootEl, "clientHeight", { configurable: true, value: 300 });
|
||||
const originalRect = dom.window.HTMLElement.prototype.getBoundingClientRect;
|
||||
dom.window.HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRect() {
|
||||
const top = this.hasAttribute("data-markdown-older-sentinel")
|
||||
|
||||
@@ -18,6 +18,10 @@ import {
|
||||
type MarkdownBlock,
|
||||
} from "../lib/markdownPipeline";
|
||||
import { getMarkdownWorkerClient } from "../lib/markdownWorkerClient";
|
||||
import {
|
||||
nativeTranscriptDistanceFromBottom,
|
||||
TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX,
|
||||
} from "../lib/transcriptScrollGeometry";
|
||||
import { getTranscriptStore } from "../lib/transcriptStore";
|
||||
import { createComponents } from "./markdownComponents";
|
||||
import { VirtualMarkdownSourceTable } from "./MarkdownTable";
|
||||
@@ -34,7 +38,6 @@ const MARKDOWN_WINDOW_BLOCKS = MARKDOWN_TAIL_BLOCKS + MARKDOWN_PREPEND_BLOCKS *
|
||||
const MARKDOWN_SENTINEL_STYLE = { display: "block", height: 1 } as const;
|
||||
const MARKDOWN_ANCHOR_STYLE = { display: "block", height: 0 } as const;
|
||||
const MARKDOWN_FALLBACK_MARKER_STYLE = { display: "none" } as const;
|
||||
const MARKDOWN_PARSE_SWAP_BOTTOM_EPSILON_PX = 2;
|
||||
|
||||
type BlockWindow = {
|
||||
identity: MarkdownBlock[] | undefined;
|
||||
@@ -57,6 +60,23 @@ function cachedBlocks(cacheKey: string | undefined, revision: number, text: stri
|
||||
return cached && cached.source === text ? cached.blocks : undefined;
|
||||
}
|
||||
|
||||
/** Conservatively detect whether the pending history row intersects its scroller. */
|
||||
function fallbackRowIntersectsTranscript(marker: HTMLElement | null, scroller: HTMLElement): boolean {
|
||||
const row = marker?.closest<HTMLElement>(".transcript__row") ?? null;
|
||||
if (!row || scroller.clientHeight <= 0) return true;
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
const scrollerRect = scroller.getBoundingClientRect();
|
||||
if (
|
||||
!Number.isFinite(rowRect.top)
|
||||
|| !Number.isFinite(rowRect.bottom)
|
||||
|| !Number.isFinite(scrollerRect.top)
|
||||
|| rowRect.bottom <= rowRect.top
|
||||
) return true;
|
||||
const viewportTop = scrollerRect.top;
|
||||
const viewportBottom = viewportTop + scroller.clientHeight;
|
||||
return rowRect.bottom > viewportTop && rowRect.top < viewportBottom;
|
||||
}
|
||||
|
||||
/** Keep a bounded block window whose edges advance only on viewport demand. */
|
||||
function useProgressiveBlockWindow(total: number, identity: MarkdownBlock[] | undefined): [BlockWindow, (direction: "older" | "newer") => void] {
|
||||
const initial = useMemo<BlockWindow>(() => ({
|
||||
@@ -179,17 +199,39 @@ export const MarkdownHistory = memo(function MarkdownHistory({
|
||||
};
|
||||
const scroller = fallbackMarkerRef.current?.closest<HTMLElement>(".transcript") ?? null;
|
||||
const isAtBottom = () => !scroller
|
||||
|| scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight <= MARKDOWN_PARSE_SWAP_BOTTOM_EPSILON_PX;
|
||||
|| nativeTranscriptDistanceFromBottom(scroller) <= TRANSCRIPT_AT_BOTTOM_THRESHOLD_PX;
|
||||
if (isAtBottom()) {
|
||||
commit();
|
||||
return;
|
||||
}
|
||||
// A reader who is not at the bottom does not have to lose the
|
||||
// rendered view (#9570): the deferred handoff below is only needed
|
||||
// when swapping the fallback for the bounded tail window would
|
||||
// visibly remove content the reader is looking at.
|
||||
//
|
||||
// 1. Answers within one tail window (total <= MARKDOWN_TAIL_BLOCKS)
|
||||
// render the full document in the block window — nothing is
|
||||
// removed, so the swap cannot yank the scroller.
|
||||
if (result.blocks.length <= MARKDOWN_TAIL_BLOCKS) {
|
||||
commit();
|
||||
return;
|
||||
}
|
||||
// 2. Longer answers outside the transcript viewport swap safely: any height
|
||||
// change happens off-screen, and the reader scrolling up meets
|
||||
// rendered blocks instead of the raw source.
|
||||
// Measure the real Virtuoso row, not the display:none marker: hidden
|
||||
// elements have an empty DOMRect and the app window is not the
|
||||
// transcript's scroll viewport.
|
||||
if (scroller && !fallbackRowIntersectsTranscript(fallbackMarkerRef.current, scroller)) {
|
||||
commit();
|
||||
return;
|
||||
}
|
||||
|
||||
// A fresh history mount uses the complete plain-text source while the
|
||||
// worker parses. Replacing that source mid-read with the bounded tail
|
||||
// block window removes the reader's visible blocks from the DOM and
|
||||
// makes the native scroller jump to the end. Cache the result above,
|
||||
// but keep the stable fallback until the reader deliberately returns
|
||||
// 3. A long answer the reader is currently looking at keeps the
|
||||
// stable fallback: replacing the complete plain-text source with the
|
||||
// bounded tail block window removes the visible blocks from the DOM
|
||||
// and makes the native scroller jump to the end. Cache the result
|
||||
// above, but keep the fallback until the reader deliberately returns
|
||||
// to the bottom; that handoff needs no competing scroll write.
|
||||
const handleScroll = () => {
|
||||
if (isAtBottom()) commit();
|
||||
|
||||
Reference in new issue
Block a user