mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
perf(desktop): cache parsed markdown across channel-switch remounts (#1635)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
@@ -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 (`[](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 (`[](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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user