perf(desktop): cache parsed markdown across channel-switch remounts (#1635)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Noah
2026-07-08 14:38:52 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 9b244ff22b
commit 2b32469340
10 changed files with 948 additions and 249 deletions
+1
View File
@@ -73,6 +73,7 @@ export default defineConfig({
"**/channel-dense-second-reach.spec.ts",
"**/channel-window-mock-paging.spec.ts",
"**/live-broadcast-reply-timeline.spec.ts",
"**/markdown-parse-cache.spec.ts",
"**/overscroll-boundary.spec.ts",
"**/cold-switch-longtask.perf.ts",
"**/timeline-no-shift.spec.ts",
@@ -17,6 +17,7 @@ import { resetActiveAgentTurnsStore } from "@/features/agents/activeAgentTurnsSt
import { resetAgentWorkingSignal } from "@/features/agents/agentWorkingSignal";
import { resetAgentObserverStore } from "@/features/agents/observerRelayStore";
import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useSidebarRelayConnectionCard";
import { clearMarkdownNodeCache } from "@/shared/ui/markdown/nodeCache";
import { resetVideoPlayerState } from "@/shared/ui/videoPlayerState";
import { initFirstWorkspace } from "./workspaceStorage";
@@ -40,6 +41,7 @@ function resetWorkspaceState(): void {
resetRenderScopedReactionHydration();
clearSearchHitEventCache();
clearAllDrafts();
clearMarkdownNodeCache();
}
type WorkspaceInitResult =
+215 -249
View File
@@ -1,6 +1,6 @@
import * as React from "react";
import { createPortal } from "react-dom";
import ReactMarkdown, { type Components } from "react-markdown";
import type { Components } from "react-markdown";
import {
ChevronLeft,
ChevronRight,
@@ -9,8 +9,6 @@ import {
ZoomOut,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import { toast } from "sonner";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
@@ -29,13 +27,6 @@ import {
} from "@/shared/lib/linkPreview";
import { useResolvedLinkPreviews } from "@/shared/lib/useResolvedLinkPreviews";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import rehypeImageGallery from "@/shared/lib/rehypeImageGallery";
import rehypeSearchHighlight from "@/shared/lib/rehypeSearchHighlight";
import remarkChannelLinks from "@/shared/lib/remarkChannelLinks";
import remarkCustomEmoji from "@/shared/lib/remarkCustomEmoji";
import remarkMentions from "@/shared/lib/remarkMentions";
import remarkSpoilers from "@/shared/lib/remarkSpoilers";
import remarkMessageLinks from "@/features/messages/lib/remarkMessageLinks";
import { AttachmentGroup } from "@/shared/ui/attachment";
import { ConfigNudgeCard } from "@/shared/ui/config-nudge-attachment";
import { LinkPreviewAttachment } from "@/shared/ui/link-preview-attachment";
@@ -75,84 +66,28 @@ import { MarkdownInput } from "./markdown/MarkdownInput";
import { MarkdownTable } from "./markdown/MarkdownTable";
import { MaskedLinkTooltip } from "./markdown/MaskedLinkTooltip";
import { MessageLinkPill } from "./markdown/MessageLinkPill";
import { renderCachedMarkdown } from "./markdown/nodeCache";
import {
MarkdownRuntimeContext,
useMarkdownRuntime,
} from "./markdown/runtimeContext";
import { resolveFileCard } from "./markdownFileCard";
import type {
ImetaEntry,
MarkdownProps,
MarkdownRuntime,
} from "./markdown/types";
import type { MarkdownProps, MarkdownRuntime } from "./markdown/types";
import { SpoilerInline } from "./markdown/SpoilerInline";
import {
aspectRatioFromDim,
dimensionsFromDim,
getDecodedImageDimensions,
imageReserveStyle,
isInsideHiddenSpoiler,
getReactNodeText,
messageLinkUrlTransform,
rememberDecodedImageDimensions,
useFrozenImageReserve,
useStableArray,
} from "./markdown/utils";
import { VideoPlayer, type VideoReviewContext } from "./VideoPlayer";
/**
* Video review context flows through React context instead of
* `createMarkdownComponents` arguments. The component map must keep a stable
* identity across re-renders: a new map means new element types, which makes
* React unmount and remount every rendered node — including `<video>`
* elements, killing playback (and any in-progress review comment draft)
* whenever the timeline re-renders.
*/
const VideoReviewMarkdownContext = React.createContext<
VideoReviewContext | undefined
>(undefined);
function useLatestRef<T>(value: T) {
const ref = React.useRef(value);
ref.current = value;
return ref;
}
function MarkdownVideoPlayer({
alt,
entry,
resolvedSrc,
src,
}: {
alt?: string;
entry?: ImetaEntry;
resolvedSrc: string;
src?: string;
}) {
const videoReviewContext = React.useContext(VideoReviewMarkdownContext);
// Look up poster frame from imeta tags (NIP-71 `image` field).
// Fall back to `thumb` for compatibility with older events.
const posterUrl = entry?.image ?? entry?.thumb;
const resolvedPoster = posterUrl ? rewriteRelayUrl(posterUrl) : undefined;
const resolvedReviewContext = React.useMemo(
() =>
videoReviewContext
? {
...videoReviewContext,
title:
videoReviewContext.title ?? entry?.filename ?? alt ?? "Video",
}
: undefined,
[alt, entry?.filename, videoReviewContext],
);
return (
<VideoPlayer
src={resolvedSrc}
aspectRatio={aspectRatioFromDim(entry?.dim)}
poster={resolvedPoster}
durationSeconds={entry?.duration}
reviewKey={src ?? resolvedSrc}
reviewContext={resolvedReviewContext}
/>
);
}
import {
MarkdownVideoPlayer,
VideoReviewMarkdownContext,
} from "./markdown/MarkdownVideoPlayer";
type ImageLightboxBox = {
height: number;
@@ -1560,13 +1495,109 @@ function ImageBlock({ alt, dim, resolvedSrc, src }: ImageBlockProps) {
}
function createMarkdownComponents(
runtimeRef: React.RefObject<MarkdownRuntime>,
interactive = true,
mediaInset = false,
): Components {
const listItemClassName = "[&_p]:inline";
const listClassName = "space-y-1 pl-6 marker:text-muted-foreground/80";
function MarkdownAnchor({
children,
href,
...props
}: React.ComponentPropsWithoutRef<"a">) {
const { channels, imetaByUrl, onOpenMessageLink } = useMarkdownRuntime();
if (!interactive) {
return <span className="font-medium text-current">{children}</span>;
}
// Markdown image-link syntax (`[![alt](src)](href)`) otherwise nests the
// image lightbox button inside an anchor. Keep the image as the lightbox
// trigger and suppress the parent link activation for block media.
if (hasBlockMedia(React.Children.toArray(children))) {
return <>{children}</>;
}
const label = getReactNodeText(children);
// Generic file attachment: a `[filename](url)` link whose href matches an
// imeta entry with a non-image, non-video MIME. Render a download card
// instead of a plain link. (Media uses the `img` renderer, not this path.)
const card = resolveFileCard(
href ? imetaByUrl?.get(href) : undefined,
href,
label,
);
if (card) {
return (
<FileCard href={card.href} filename={card.filename} size={card.size} />
);
}
// Intercept `buzz://message?channel=…&id=…` links so a click navigates
// in-app instead of opening the URL in the OS browser. http(s) links
// continue to use the existing target="_blank" behavior.
if (href) {
const messageLinkTarget = resolveMessageLinkRenderTarget({
href,
label,
});
if (messageLinkTarget.kind !== "none") {
if (messageLinkTarget.kind === "pill") {
return (
<MessageLinkPill
channels={channels}
href={href}
interactive={interactive}
link={messageLinkTarget.link}
onOpenMessageLink={onOpenMessageLink}
/>
);
}
return (
<a
{...props}
className="font-medium text-primary underline underline-offset-4 transition-colors hover:text-primary/80 cursor-pointer"
href={href}
onClick={(event) => {
event.preventDefault();
onOpenMessageLink(messageLinkTarget.link);
}}
>
{children}
</a>
);
}
// Malformed message deep link — fall through to the default
// anchor (renders as a normal external link).
}
const supportedLinkPreview = href ? parseSupportedLinkPreview(href) : null;
const isLinearLink = supportedLinkPreview?.kind === "linear-issue";
const anchor = (
<a
{...props}
className={cn(
"font-medium underline underline-offset-4 transition-colors",
isLinearLink ? "linear-link" : "text-primary hover:text-primary/80",
)}
href={href}
rel="noreferrer"
target="_blank"
>
{children}
</a>
);
return (
<MaskedLinkTooltip disabled={isLinearLink} href={href} label={label}>
{anchor}
</MaskedLinkTooltip>
);
}
return {
spoiler: ({
children,
@@ -1582,104 +1613,7 @@ function createMarkdownComponents(
{children}
</SpoilerInline>
),
a: ({ children, href, ...props }) => {
const { imetaByUrl, onOpenMessageLink } = runtimeRef.current;
if (!interactive) {
return <span className="font-medium text-current">{children}</span>;
}
// Markdown image-link syntax (`[![alt](src)](href)`) otherwise nests the
// image lightbox button inside an anchor. Keep the image as the lightbox
// trigger and suppress the parent link activation for block media.
if (hasBlockMedia(React.Children.toArray(children))) {
return <>{children}</>;
}
const label = getReactNodeText(children);
// Generic file attachment: a `[filename](url)` link whose href matches an
// imeta entry with a non-image, non-video MIME. Render a download card
// instead of a plain link. (Media uses the `img` renderer, not this path.)
const card = resolveFileCard(
href ? imetaByUrl?.get(href) : undefined,
href,
label,
);
if (card) {
return (
<FileCard
href={card.href}
filename={card.filename}
size={card.size}
/>
);
}
// Intercept `buzz://message?channel=…&id=…` links so a click navigates
// in-app instead of opening the URL in the OS browser. http(s) links
// continue to use the existing target="_blank" behavior.
if (href) {
const messageLinkTarget = resolveMessageLinkRenderTarget({
href,
label,
});
if (messageLinkTarget.kind !== "none") {
if (messageLinkTarget.kind === "pill") {
return (
<MessageLinkPill
channels={runtimeRef.current.channels}
href={href}
interactive={interactive}
link={messageLinkTarget.link}
onOpenMessageLink={onOpenMessageLink}
/>
);
}
return (
<a
{...props}
className="font-medium text-primary underline underline-offset-4 transition-colors hover:text-primary/80 cursor-pointer"
href={href}
onClick={(event) => {
event.preventDefault();
onOpenMessageLink(messageLinkTarget.link);
}}
>
{children}
</a>
);
}
// Malformed message deep link — fall through to the default
// anchor (renders as a normal external link).
}
const supportedLinkPreview = href
? parseSupportedLinkPreview(href)
: null;
const isLinearLink = supportedLinkPreview?.kind === "linear-issue";
const anchor = (
<a
{...props}
className={cn(
"font-medium underline underline-offset-4 transition-colors",
isLinearLink ? "linear-link" : "text-primary hover:text-primary/80",
)}
href={href}
rel="noreferrer"
target="_blank"
>
{children}
</a>
);
return (
<MaskedLinkTooltip disabled={isLinearLink} href={href} label={label}>
{anchor}
</MaskedLinkTooltip>
);
},
a: MarkdownAnchor,
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-border pl-4 italic text-muted-foreground [&>*:first-child]:mt-0 [&>*+*]:mt-2">
{children}
@@ -1751,8 +1685,8 @@ function createMarkdownComponents(
</h6>
),
hr: () => <hr className="border-border/80" />,
img: ({ alt, src }) => {
const { imetaByUrl } = runtimeRef.current;
img: function MarkdownImage({ alt, src }) {
const { imetaByUrl } = useMarkdownRuntime();
const resolvedSrc = src ? rewriteRelayUrl(src) : src;
if (!interactive) {
const fallbackLabel = resolvedSrc?.endsWith(".mp4")
@@ -1851,9 +1785,13 @@ function createMarkdownComponents(
ul: ({ children }) => (
<ul className={cn("list-disc", listClassName)}>{children}</ul>
),
mention: ({ children }: { children?: React.ReactNode }) => {
mention: function MarkdownMention({
children,
}: {
children?: React.ReactNode;
}) {
const { agentMentionPubkeysByName, mentionPubkeysByName } =
runtimeRef.current;
useMarkdownRuntime();
const mentionText = String(children ?? "");
const mentionName = mentionText.replace(/^@/, "").trim().toLowerCase();
const pubkey = mentionPubkeysByName?.[mentionName];
@@ -1910,8 +1848,12 @@ function createMarkdownComponents(
}
return <InlineEmojiPopover alt={alt} resolvedSrc={resolvedSrc} />;
},
"channel-link": ({ children }: { children?: React.ReactNode }) => {
const { channels, onOpenChannel } = runtimeRef.current;
"channel-link": function MarkdownChannelLink({
children,
}: {
children?: React.ReactNode;
}) {
const { channels, onOpenChannel } = useMarkdownRuntime();
const text = String(children ?? "");
const channelName = text.startsWith("#") ? text.slice(1) : text;
const channel = channels.find(
@@ -1946,8 +1888,12 @@ function createMarkdownComponents(
</span>
);
},
"message-link": ({ children }: { children?: React.ReactNode }) => {
const { channels, onOpenMessageLink } = runtimeRef.current;
"message-link": function MarkdownMessageLink({
children,
}: {
children?: React.ReactNode;
}) {
const { channels, onOpenMessageLink } = useMarkdownRuntime();
const href = String(children ?? "");
const parsed = parseMessageLink(href);
if (!parsed.ok) {
@@ -1969,6 +1915,38 @@ function createMarkdownComponents(
} as Components;
}
/**
* The component map only varies by the two boolean render flags, so at most
* four instances ever exist. Module-stable maps mean cached markdown element
* trees (see ./markdown/nodeCache.ts) never embed per-mount closures.
*/
const markdownComponentsByVariant = new Map<string, MarkdownComponentSet>();
type MarkdownComponentSet = { components: Components; variant: string };
/**
* Returns the component map together with the `variant` token that fully
* identifies it. The token doubles as the variant segment of the parse-cache
* key (see nodeCache.ts), so the map partitioning and the key partitioning
* come from one place and cannot drift apart: a new render flag added here
* automatically partitions the cache too.
*/
function getMarkdownComponents(
interactive: boolean,
mediaInset: boolean,
): MarkdownComponentSet {
const variant = `${interactive ? "i" : ""}${mediaInset ? "m" : ""}`;
let entry = markdownComponentsByVariant.get(variant);
if (!entry) {
entry = {
components: createMarkdownComponents(interactive, mediaInset),
variant,
};
markdownComponentsByVariant.set(variant, entry);
}
return entry;
}
function MarkdownInner({
channelNames,
className,
@@ -2017,44 +1995,25 @@ function MarkdownInner({
() => computeConfigNudge(content, interactive, configNudgeAuthorPubkey),
[content, interactive, configNudgeAuthorPubkey],
);
const runtimeRef = useLatestRef<MarkdownRuntime>({
agentMentionPubkeysByName,
channels,
imetaByUrl,
mentionPubkeysByName,
onOpenChannel,
onOpenMessageLink,
});
const components = React.useMemo(
() => createMarkdownComponents(runtimeRef, interactive, mediaInset),
[runtimeRef, interactive, mediaInset],
);
// biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable
const remarkPlugins = React.useMemo<any[]>(
() => [
remarkGfm,
remarkBreaks,
remarkSpoilers,
remarkMessageLinks,
[remarkMentions, { mentionNames }],
[remarkChannelLinks, { channelNames }],
[remarkCustomEmoji, { customEmoji }],
const runtime = React.useMemo<MarkdownRuntime>(
() => ({
agentMentionPubkeysByName,
channels,
imetaByUrl,
mentionPubkeysByName,
onOpenChannel,
onOpenMessageLink,
}),
[
agentMentionPubkeysByName,
channels,
imetaByUrl,
mentionPubkeysByName,
onOpenChannel,
onOpenMessageLink,
],
[mentionNames, channelNames, customEmoji],
);
// biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable
const rehypePlugins = React.useMemo<any[]>(() => {
// biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable
const plugins: any[] = [rehypeImageGallery];
if (searchQuery && searchQuery.trim().length >= 2) {
plugins.push([rehypeSearchHighlight, { query: searchQuery }]);
}
return plugins;
}, [searchQuery]);
let processedContent = content;
// Note: stripping the sentinel here is intentionally omitted. When
@@ -2072,16 +2031,21 @@ function MarkdownInner({
const resolvedLinkPreviews = useResolvedLinkPreviews(linkPreviews);
const markdownNode = (
<ReactMarkdown
components={components}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
urlTransform={messageLinkUrlTransform}
>
{processedContent}
</ReactMarkdown>
);
// When a config-nudge suppresses the prose (selectProseOrNudge returns
// null), skip the parse entirely — it would be thrown away unrendered.
const componentSet = getMarkdownComponents(interactive, mediaInset);
const markdownNode =
configNudge === null
? renderCachedMarkdown({
channelNames,
components: componentSet.components,
content: processedContent,
customEmoji,
mentionNames,
searchQuery,
variant: componentSet.variant,
})
: null;
return (
<div
@@ -2104,27 +2068,29 @@ function MarkdownInner({
className,
)}
>
<VideoReviewMarkdownContext.Provider value={videoReviewContext}>
{selectProseOrNudge(configNudge, markdownNode)}
{configNudge !== null ? (
<AttachmentGroup
className="max-w-full flex-wrap overflow-visible pb-0"
data-config-nudge=""
>
<ConfigNudgeCard nudge={configNudge} />
</AttachmentGroup>
) : null}
{resolvedLinkPreviews.length > 0 ? (
<AttachmentGroup
className="max-w-full flex-wrap overflow-visible pb-0"
data-link-preview-list=""
>
{resolvedLinkPreviews.map((preview) => (
<LinkPreviewAttachment key={preview.href} preview={preview} />
))}
</AttachmentGroup>
) : null}
</VideoReviewMarkdownContext.Provider>
<MarkdownRuntimeContext.Provider value={runtime}>
<VideoReviewMarkdownContext.Provider value={videoReviewContext}>
{selectProseOrNudge(configNudge, markdownNode)}
{configNudge !== null ? (
<AttachmentGroup
className="max-w-full flex-wrap overflow-visible pb-0"
data-config-nudge=""
>
<ConfigNudgeCard nudge={configNudge} />
</AttachmentGroup>
) : null}
{resolvedLinkPreviews.length > 0 ? (
<AttachmentGroup
className="max-w-full flex-wrap overflow-visible pb-0"
data-link-preview-list=""
>
{resolvedLinkPreviews.map((preview) => (
<LinkPreviewAttachment key={preview.href} preview={preview} />
))}
</AttachmentGroup>
) : null}
</VideoReviewMarkdownContext.Provider>
</MarkdownRuntimeContext.Provider>
</div>
);
}
@@ -0,0 +1,59 @@
import * as React from "react";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { VideoPlayer, type VideoReviewContext } from "../VideoPlayer";
import type { ImetaEntry } from "./types";
import { aspectRatioFromDim } from "./utils";
/**
* Video review context flows through React context instead of
* `createMarkdownComponents` arguments. The component map must keep a stable
* identity across re-renders: a new map means new element types, which makes
* React unmount and remount every rendered node including `<video>`
* elements, killing playback (and any in-progress review comment draft)
* whenever the timeline re-renders.
*/
export const VideoReviewMarkdownContext = React.createContext<
VideoReviewContext | undefined
>(undefined);
export function MarkdownVideoPlayer({
alt,
entry,
resolvedSrc,
src,
}: {
alt?: string;
entry?: ImetaEntry;
resolvedSrc: string;
src?: string;
}) {
const videoReviewContext = React.useContext(VideoReviewMarkdownContext);
// Look up poster frame from imeta tags (NIP-71 `image` field).
// Fall back to `thumb` for compatibility with older events.
const posterUrl = entry?.image ?? entry?.thumb;
const resolvedPoster = posterUrl ? rewriteRelayUrl(posterUrl) : undefined;
const resolvedReviewContext = React.useMemo(
() =>
videoReviewContext
? {
...videoReviewContext,
title:
videoReviewContext.title ?? entry?.filename ?? alt ?? "Video",
}
: undefined,
[alt, entry?.filename, videoReviewContext],
);
return (
<VideoPlayer
src={resolvedSrc}
aspectRatio={aspectRatioFromDim(entry?.dim)}
poster={resolvedPoster}
durationSeconds={entry?.duration}
reviewKey={src ?? resolvedSrc}
reviewContext={resolvedReviewContext}
/>
);
}
@@ -0,0 +1,117 @@
import assert from "node:assert/strict";
import test from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { clearMarkdownNodeCache, renderCachedMarkdown } from "./nodeCache.ts";
// The whole point of the cache is element-identity reuse across the message
// timeline's per-channel-switch remount: same parse inputs must return the
// SAME element (no re-parse), and anything that changes the parse output
// must miss.
const BASE = {
components: {},
content: "**bold** and `code`",
variant: "i",
};
test("same parse inputs return the identical cached element", () => {
clearMarkdownNodeCache();
const first = renderCachedMarkdown({ ...BASE });
const second = renderCachedMarkdown({ ...BASE });
assert.equal(first, second);
assert.match(renderToStaticMarkup(first), /<strong>bold<\/strong>/);
});
test("content changes miss the cache", () => {
clearMarkdownNodeCache();
const first = renderCachedMarkdown({ ...BASE });
const second = renderCachedMarkdown({ ...BASE, content: "**bald**" });
assert.notEqual(first, second);
});
test("customEmoji is keyed by value, not identity", () => {
clearMarkdownNodeCache();
const emoji = [{ shortcode: "buzz", url: "https://relay/buzz.png" }];
const first = renderCachedMarkdown({
...BASE,
content: "hi :buzz:",
customEmoji: emoji,
});
// Fresh array, same values — the exact remount scenario (useMessageEmoji
// rebuilds the array): must HIT.
const second = renderCachedMarkdown({
...BASE,
content: "hi :buzz:",
customEmoji: [{ shortcode: "buzz", url: "https://relay/buzz.png" }],
});
assert.equal(first, second);
// Same content, different emoji set (e.g. emoji added while editing —
// custom-emoji.spec.ts Bug 2): must MISS so the new emoji renders.
const third = renderCachedMarkdown({
...BASE,
content: "hi :buzz:",
customEmoji: [{ shortcode: "buzz", url: "https://relay/other.png" }],
});
assert.notEqual(first, third);
});
test("mention and channel names are part of the key", () => {
clearMarkdownNodeCache();
const first = renderCachedMarkdown({
...BASE,
content: "ping @alice in #general",
mentionNames: ["alice"],
channelNames: ["general"],
});
const second = renderCachedMarkdown({
...BASE,
content: "ping @alice in #general",
mentionNames: ["alice", "bob"],
channelNames: ["general"],
});
assert.notEqual(first, second);
});
test("render variants do not collide", () => {
clearMarkdownNodeCache();
const interactive = renderCachedMarkdown({ ...BASE });
const nonInteractive = renderCachedMarkdown({ ...BASE, variant: "" });
assert.notEqual(interactive, nonInteractive);
});
test("crafted values cannot forge key-segment boundaries", () => {
clearMarkdownNodeCache();
// Length-prefixed segments: a single name containing arbitrary bytes must
// never be key-identical to two separate names, and values must not bleed
// across the mention/channel field boundary.
const joined = renderCachedMarkdown({
...BASE,
mentionNames: ["ab"],
});
const split = renderCachedMarkdown({
...BASE,
mentionNames: ["a", "b"],
});
assert.notEqual(joined, split);
const inMentions = renderCachedMarkdown({ ...BASE, mentionNames: ["x"] });
const inChannels = renderCachedMarkdown({ ...BASE, channelNames: ["x"] });
assert.notEqual(inMentions, inChannels);
});
test("oversized content bypasses the cache", () => {
clearMarkdownNodeCache();
const huge = { ...BASE, content: "a".repeat(40_000) };
const first = renderCachedMarkdown(huge);
const second = renderCachedMarkdown(huge);
assert.notEqual(first, second);
});
test("active search queries bypass the cache", () => {
clearMarkdownNodeCache();
const first = renderCachedMarkdown({ ...BASE, searchQuery: "bold" });
const second = renderCachedMarkdown({ ...BASE, searchQuery: "bold" });
assert.notEqual(first, second);
});
+158
View File
@@ -0,0 +1,158 @@
import type * as React from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import remarkMessageLinks from "@/features/messages/lib/remarkMessageLinks";
import rehypeImageGallery from "@/shared/lib/rehypeImageGallery";
import rehypeSearchHighlight from "@/shared/lib/rehypeSearchHighlight";
import remarkChannelLinks from "@/shared/lib/remarkChannelLinks";
import remarkCustomEmoji, {
type CustomEmoji,
} from "@/shared/lib/remarkCustomEmoji";
import remarkMentions from "@/shared/lib/remarkMentions";
import remarkSpoilers from "@/shared/lib/remarkSpoilers";
import { messageLinkUrlTransform } from "./utils";
/**
* Parsed-markdown element cache.
*
* The message timeline's scroll container is keyed by channel id (see
* MessageTimeline required so TanStack Router's scroll restoration never
* writes a stale scrollTop into a reused scroll node), so every channel
* switch remounts every row and `React.memo` cannot carry the react-markdown
* parse across the remount. react-markdown's `Markdown` is a plain
* synchronous hook-free function, so its element tree is a pure function of
* the parse inputs below and can be reused across mounts. Everything
* per-mount (channels, imeta lookup, navigation callbacks) flows through
* `MarkdownRuntimeContext`, read at render time a cached element never
* captures per-mount state. The `components` map passed in must be
* module-stable and fully identified by `variant` (see
* `getMarkdownComponents`) the map itself is deliberately not part of the
* cache key.
*
* Recency-ordered via Map insertion order; capacity comfortably covers two
* window-ceiling channels' worth of rows.
*/
const MARKDOWN_NODE_CACHE_LIMIT = 1000;
/** Oversized messages (large agent pastes) bypass the cache: they rarely
* repeat enough to benefit, and each entry would retain the full content in
* both the key and the element tree. Mirrors the searchQuery bypass. */
const MARKDOWN_NODE_CACHE_MAX_CONTENT_LENGTH = 32_000;
const markdownNodeCache = new Map<string, React.ReactElement>();
/** Workspace switches swap relays; drop parses keyed against the old
* workspace's mention/channel-name space (see `resetWorkspaceState`). */
export function clearMarkdownNodeCache() {
markdownNodeCache.clear();
}
let markdownParseCount = 0;
/** Number of fresh react-markdown parses since app start (cache misses and
* bypasses). Exposed through the e2e bridge so specs can assert that warm
* channel switches are pure cache hits (zero fresh parses). */
export function getMarkdownParseCount(): number {
return markdownParseCount;
}
/** Inputs that fully determine the parsed element tree. `variant` identifies
* the module-stable `components` map (see `getMarkdownComponents`); the two
* must always come from the same call so they cannot drift apart. */
export type MarkdownParseInputs = {
channelNames?: string[];
components: Components;
content: string;
customEmoji?: CustomEmoji[];
mentionNames?: string[];
searchQuery?: string;
variant: string;
};
/** Length-prefix a segment so no value can forge a boundary an injective
* encoding regardless of the bytes in relay-controlled names and URLs. */
function segment(value: string): string {
return `${value.length}:${value}`;
}
function listSegment(values: readonly string[] | undefined): string {
return segment(values?.map(segment).join("") ?? "");
}
function buildMarkdownElement(input: MarkdownParseInputs): React.ReactElement {
markdownParseCount += 1;
// biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable
const rehypePlugins: any[] = [rehypeImageGallery];
if (input.searchQuery && input.searchQuery.trim().length >= 2) {
rehypePlugins.push([rehypeSearchHighlight, { query: input.searchQuery }]);
}
// Called as a plain function rather than rendered as <ReactMarkdown/>:
// react-markdown's `Markdown` is synchronous and hook-free (the hook
// variant is `MarkdownHooks`), so this returns the parsed element tree
// directly, which is what lets it live in a module-level cache.
return ReactMarkdown({
children: input.content,
components: input.components,
remarkPlugins: [
remarkGfm,
remarkBreaks,
remarkSpoilers,
remarkMessageLinks,
[remarkMentions, { mentionNames: input.mentionNames }],
[remarkChannelLinks, { channelNames: input.channelNames }],
[remarkCustomEmoji, { customEmoji: input.customEmoji }],
// biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable
] as any[],
rehypePlugins,
urlTransform: messageLinkUrlTransform,
});
}
/** Return the parsed element tree for the given inputs, reusing a cached
* tree when an identical parse has been done before. See the module doc
* comment for why this is safe. */
export function renderCachedMarkdown(
input: MarkdownParseInputs,
): React.ReactElement {
// Search highlighting is transient and query-specific: parse fresh rather
// than churn the cache with per-query variants. Oversized content parses
// fresh too — see MARKDOWN_NODE_CACHE_MAX_CONTENT_LENGTH.
if (
(input.searchQuery && input.searchQuery.trim().length >= 2) ||
input.content.length > MARKDOWN_NODE_CACHE_MAX_CONTENT_LENGTH
) {
return buildMarkdownElement(input);
}
// Everything that changes the parse output must be in the key. Arrays are
// keyed by value, not identity — callers rebuild them across mounts. Every
// field is length-prefixed, so relay-controlled values cannot collide two
// distinct input tuples. Content is last and needs no prefix: everything
// before it is self-delimiting.
const key =
segment(input.variant) +
listSegment(input.mentionNames) +
listSegment(input.channelNames) +
listSegment(
input.customEmoji?.map(
(emoji) => segment(emoji.shortcode) + segment(emoji.url),
),
) +
input.content;
const hit = markdownNodeCache.get(key);
if (hit) {
markdownNodeCache.delete(key);
markdownNodeCache.set(key, hit);
return hit;
}
const element = buildMarkdownElement(input);
markdownNodeCache.set(key, element);
if (markdownNodeCache.size > MARKDOWN_NODE_CACHE_LIMIT) {
const oldest = markdownNodeCache.keys().next().value;
if (oldest !== undefined) {
markdownNodeCache.delete(oldest);
}
}
return element;
}
@@ -0,0 +1,27 @@
import * as React from "react";
import type { MarkdownRuntime } from "./types";
/**
* Per-mount runtime (channels, imeta lookup, navigation callbacks) flows
* through context for the same reason as `VideoReviewMarkdownContext` in
* markdown.tsx: the component map must stay identity-stable. Routing runtime
* through context (read at render time) rather than a closed-over ref
* additionally makes the map module-stable across mounts, which is what
* allows the parsed markdown element cache (`nodeCache.ts`) to reuse element
* trees across the timeline's per-channel-switch remount without capturing
* stale per-mount state.
*/
const INERT_MARKDOWN_RUNTIME: MarkdownRuntime = {
channels: [],
onOpenChannel: () => {},
onOpenMessageLink: () => {},
};
export const MarkdownRuntimeContext = React.createContext<MarkdownRuntime>(
INERT_MARKDOWN_RUNTIME,
);
export function useMarkdownRuntime(): MarkdownRuntime {
return React.useContext(MarkdownRuntimeContext);
}
+3
View File
@@ -7,6 +7,7 @@ import { parse as yamlParse } from "yaml";
import { relayClient } from "@/shared/api/relayClient";
import type { ConnectionState } from "@/shared/api/relayClientShared";
import type { RelayEvent } from "@/shared/api/types";
import { getMarkdownParseCount } from "@/shared/ui/markdown/nodeCache";
import { syncAgentTurnsFromEvents } from "@/features/agents/activeAgentTurnsStore";
import {
injectObserverEventsForE2E,
@@ -756,6 +757,7 @@ declare global {
__BUZZ_E2E_QUERY_CLIENT__?: {
invalidateQueries: (filters: { queryKey: readonly unknown[] }) => unknown;
};
__BUZZ_E2E_MD_PARSE_COUNT__?: () => number;
}
}
@@ -7860,6 +7862,7 @@ export function maybeInstallE2eTauriMocks() {
window.dispatchEvent(new CustomEvent("buzz:e2e-home-feed-updated"));
return item;
};
window.__BUZZ_E2E_MD_PARSE_COUNT__ = getMarkdownParseCount;
window.__BUZZ_E2E_EMIT_MOCK_READ_STATE__ = ({
clientId,
contexts,
@@ -0,0 +1,73 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
/**
* Regression gate for the markdown parse cache (shared/ui/markdown/nodeCache).
*
* The timeline's per-channel-switch remount used to re-run every row's
* react-markdown parse; the cache makes warm re-entries pure element reuse.
* This spec asserts the deterministic invariant behind that win: a warm
* channel switch performs ZERO fresh parses. Unlike the wall-clock
* benchmark (warm-switch-markdown.perf.ts, instrument-only), this is
* machine-independent and safe to gate CI on if someone disconnects
* MarkdownInner from the cache or breaks the key, this fails.
*/
async function parseCount(page: import("@playwright/test").Page) {
return page.evaluate(() => window.__BUZZ_E2E_MD_PARSE_COUNT__?.() ?? -1);
}
async function settleChannel(
page: import("@playwright/test").Page,
title: string,
) {
await expect(page.getByTestId("chat-title")).toHaveText(title);
await expect(page.locator('[data-render-pending="true"]')).toHaveCount(0);
await expect(
page.getByTestId("message-timeline").locator("[data-message-id]").first(),
).toBeVisible();
// Let any trailing deferred commits flush before reading the counter.
await page.waitForTimeout(300);
}
test("warm channel switches trigger zero fresh markdown parses", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_MD_PARSE_COUNT__ === "function",
);
// Cold visits populate the query caches and the markdown node cache.
await page.getByTestId("channel-deep-history").click();
await settleChannel(page, "deep-history");
await page.getByTestId("channel-general").click();
await settleChannel(page, "general");
const afterCold = await parseCount(page);
// Sanity: the cold visits really parsed rows (counter is wired up).
expect(afterCold).toBeGreaterThan(10);
// Warm switches: every row must be a cache hit.
let previous = afterCold;
for (let round = 0; round < 2; round += 1) {
await page.getByTestId("channel-deep-history").click();
await settleChannel(page, "deep-history");
const inDeepHistory = await parseCount(page);
expect(
inDeepHistory - previous,
`warm switch into deep-history (round ${round}) re-parsed markdown`,
).toBe(0);
await page.getByTestId("channel-general").click();
await settleChannel(page, "general");
const inGeneral = await parseCount(page);
expect(
inGeneral - inDeepHistory,
`warm switch into general (round ${round}) re-parsed markdown`,
).toBe(0);
previous = inGeneral;
}
});
@@ -0,0 +1,293 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
/**
* Warm-channel-switch benchmark.
*
* Measures the felt cost of switching INTO a channel whose messages are
* already in the React Query cache (the everyday alt-tab-between-channels
* motion). The timeline subtree is keyed by channel id required so TanStack
* Router's scroll restoration never writes a stale scrollTop into a reused
* scroll node so every switch unmounts and remounts all rows, and each
* `MessageRow` re-runs the synchronous react-markdown parse pipeline from
* scratch. This spec is the instrument for that cost.
*
* TWO SCENARIOS, one per axis of the cost:
* plain-text `deep-history` (600 seeded one-line rows; the initial
* channel window mounts ~50 of them, verified by parse
* count): isolates the per-row remount floor.
* markdown `random` + 60 injected markdown-heavy rows (code fences,
* tables, lists, mentions, links): isolates the parse cost the
* markdown cache is meant to remove.
*
* WHAT A "SWITCH" MEASURES: performance.now() immediately before an in-page
* .click() on the sidebar link, until (chat title flipped) AND (>= 1 message
* row committed) AND (no [data-render-pending="true"], i.e. the deferred
* timeline snapshot caught up to the live one) AND a double-rAF so a frame
* actually painted. The click and the polling both run in-page so CDP
* round-trip latency never pollutes the numbers. Longtask totals are captured
* per switch as the "UI froze" axis (see cold-switch-longtask.perf.ts for the
* rationale).
*
* WARM means every measured entry is a RE-entry: each scenario does one
* untimed round-trip first so both channels' queries are cached and code
* paths are jitted. 4x CPU throttle for the same reason as the cold spec
* absolute ms are not portable across machines, but before/after deltas on
* the same machine are.
*
* Run it (from desktop/):
* pnpm build
* npx playwright test --config=playwright.perf.config.ts warm-switch-markdown.perf.ts
*
* NOTE: the perf web server reuses an existing server on :4173 if one is
* already running, kill it or make sure `dist/` is freshly built, otherwise
* you measure stale code.
*/
const MEASURED_SWITCHES = 8;
const THROTTLE_RATE = 4;
const MARKDOWN_MESSAGE_COUNT = 60;
/** One representative agent-style message: fence, table, list, mention,
* emphasis, inline code, and a link the mix real Buzz channels carry. */
function markdownBody(index: number): string {
return [
`**Update ${index}** from the build agent — _step ${index} of ${MARKDOWN_MESSAGE_COUNT}_ :tada:`,
"",
"```rust",
`fn step_${index}() -> Result<Status, Error> {`,
' let plan = load_plan("release")?;',
` plan.execute(${index})`,
"}",
"```",
"",
"| check | result | took |",
"|-------|--------|------|",
`| clippy | ok | ${index}ms |`,
`| fmt | ok | ${index + 1}ms |`,
"",
`- [x] compile stage ${index}`,
"- [ ] publish artifacts",
`- see [pipeline](https://example.com/build/${index}) or ask @alice`,
"",
`Inline \`cargo build -p step-${index}\` finished.`,
].join("\n");
}
type SwitchSample = {
ms: number;
longtaskTotal: number;
longtaskMax: number;
longtaskCount: number;
};
function median(values: number[]): number {
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid];
}
async function waitForMockLiveSubscription(
page: import("@playwright/test").Page,
channelName: string,
) {
await expect
.poll(() =>
page.evaluate(
(ch) =>
window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: ch,
}) ?? false,
channelName,
),
)
.toBe(true);
}
/** Click the sidebar link and poll all in-page until the target channel's
* rows are committed, the deferred snapshot has caught up, and a frame
* painted. Returns wall-clock ms plus the longtasks observed in the window. */
async function measureSwitch(
page: import("@playwright/test").Page,
input: { targetTestId: string; targetTitle: string; rowSelector: string },
): Promise<SwitchSample> {
return page.evaluate(async (args) => {
const store = window as unknown as { __LONGTASKS__: number[] };
store.__LONGTASKS__ = [];
const link = document.querySelector<HTMLElement>(
`[data-testid="${args.targetTestId}"]`,
);
if (!link) throw new Error(`missing sidebar link ${args.targetTestId}`);
const start = performance.now();
link.click();
await new Promise<void>((resolve, reject) => {
const deadline = start + 30_000;
const check = () => {
const title = document.querySelector(
'[data-testid="chat-title"]',
)?.textContent;
const ready =
title === args.targetTitle &&
document.querySelector(args.rowSelector) !== null &&
document.querySelector('[data-render-pending="true"]') === null;
if (ready) {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
return;
}
if (performance.now() > deadline) {
reject(new Error(`switch to ${args.targetTitle} timed out`));
return;
}
requestAnimationFrame(check);
};
requestAnimationFrame(check);
});
const elapsed = performance.now() - start;
const tasks = store.__LONGTASKS__ ?? [];
return {
ms: elapsed,
longtaskTotal: tasks.reduce((sum, duration) => sum + duration, 0),
longtaskMax: tasks.length ? Math.max(...tasks) : 0,
longtaskCount: tasks.length,
};
}, input);
}
async function runScenario(
page: import("@playwright/test").Page,
input: {
label: string;
targetTestId: string;
targetTitle: string;
rowSelector: string;
},
): Promise<SwitchSample[]> {
const back = {
targetTestId: "channel-general",
targetTitle: "general",
rowSelector: "[data-message-id]",
};
// Untimed warmup round-trip: caches both channels' queries.
await measureSwitch(page, input);
await measureSwitch(page, back);
const samples: SwitchSample[] = [];
for (let run = 0; run < MEASURED_SWITCHES; run += 1) {
samples.push(await measureSwitch(page, input));
await measureSwitch(page, back);
}
const times = samples.map((sample) => sample.ms);
const longtaskTotals = samples.map((sample) => sample.longtaskTotal);
/* eslint-disable no-console */
console.log(`\n=== WARM SWITCH: ${input.label} ===`);
console.log(`CPU throttle: ${THROTTLE_RATE}x`);
console.log(
`per-switch wall ms: [${times.map((v) => v.toFixed(1)).join(", ")}]`,
);
console.log(
`per-switch longtask ms: [${longtaskTotals.map((v) => v.toFixed(1)).join(", ")}]`,
);
console.log(`MEDIAN wall ms: ${median(times).toFixed(1)}`);
console.log(
`MEDIAN longtask total: ${median(longtaskTotals).toFixed(1)}ms`,
);
console.log(
`worst single longtask: ${Math.max(...samples.map((sample) => sample.longtaskMax)).toFixed(1)}ms`,
);
/* eslint-enable no-console */
return samples;
}
test("MEASURE: warm channel-switch cost (plain 300-row + markdown-heavy)", async ({
page,
}) => {
test.setTimeout(300_000);
await installMockBridge(page);
await page.goto("/");
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
);
// Arm the longtask observer; addInitScript applies on next navigation.
await page.addInitScript(() => {
const store = window as unknown as { __LONGTASKS__?: number[] };
store.__LONGTASKS__ = [];
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
store.__LONGTASKS__?.push(entry.duration);
}
}).observe({ type: "longtask", buffered: true });
});
await page.reload();
await page.waitForFunction(
() =>
typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
Array.isArray(
(window as unknown as { __LONGTASKS__?: number[] }).__LONGTASKS__,
),
);
// Seed `random` with markdown-heavy rows. Live emits need an active
// subscription, so enter the channel first; the mock store keeps the rows
// for every later re-entry via get_channel_window.
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await waitForMockLiveSubscription(page, "random");
await page.evaluate(
({ count, bodies }) => {
const base = Math.floor(Date.now() / 1000) - count - 10;
for (let index = 0; index < count; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "random",
content: bodies[index],
createdAt: base + index,
});
}
},
{
count: MARKDOWN_MESSAGE_COUNT,
bodies: Array.from({ length: MARKDOWN_MESSAGE_COUNT }, (_, index) =>
markdownBody(index),
),
},
);
// All injected rows committed before anything is timed.
await expect(
page.locator(`text=Update ${MARKDOWN_MESSAGE_COUNT - 1}`).first(),
).toBeVisible();
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const client = await page.context().newCDPSession(page);
await client.send("Emulation.setCPUThrottlingRate", { rate: THROTTLE_RATE });
const plain = await runScenario(page, {
label: "plain-text ~50-row window (deep-history)",
targetTestId: "channel-deep-history",
targetTitle: "deep-history",
rowSelector: '[data-message-id^="mock-deep-history-"]',
});
const markdown = await runScenario(page, {
label: `markdown-heavy x${MARKDOWN_MESSAGE_COUNT} (random)`,
targetTestId: "channel-random",
targetTitle: "random",
rowSelector: "[data-message-id]",
});
await client.send("Emulation.setCPUThrottlingRate", { rate: 1 });
// Instrument, not a gate: assert the harness measured real work.
expect(plain.length).toBe(MEASURED_SWITCHES);
expect(markdown.length).toBe(MEASURED_SWITCHES);
expect(plain.every((sample) => sample.ms > 0)).toBe(true);
expect(markdown.every((sample) => sample.ms > 0)).toBe(true);
});