Files
buzz/desktop/src/shared/ui/markdown/nodeCache.test.mjs
tulsi 29f2054c69 highlight search terms in results and messages (#6702)
**Category:** fix
**User Impact:** Search terms are now highlighted in yellow in Cmd+F
results and in the message opened from a result.

**Problem:** Search returned relevant messages, but users still had to
reread each preview and destination message to discover where the query
appeared. Search navigation could also lose or misapply highlighting
during rapid typing, repeated navigation, thread opening, and forum
navigation.

**Solution:** Render match-focused previews with a shared yellow
treatment, carry the result query through navigation, and apply it only
to the clicked destination. The implementation binds highlights to the
debounced result set, supports token and prefix matching, and handles
channels, threads, forums, diffs, and wave messages consistently.

<details>
<summary>File changes</summary>

**desktop/src/app/AppShell.tsx**
Carries the active result query into search-hit navigation so the
destination can preserve the user's context.

**desktop/src/app/navigation/searchHitEventCache.ts**
Adds a bounded, one-shot cache for result queries and navigation IDs
alongside cached search-hit events.

**desktop/src/app/navigation/searchHitNavigation.test.mjs**
Covers query retention, one-shot consumption, repeated same-route
activations, forum posts, forced routing, and cancellation.

**desktop/src/app/navigation/searchHitNavigation.ts**
Associates each search-result activation with a unique navigation ID and
forwards it to channel or forum destinations.

**desktop/src/app/navigation/useAppNavigation.ts**
Extends channel and forum navigation to carry search navigation state
without changing normal navigation behavior.

**desktop/src/app/routes/ChannelRouteScreen.tsx**
Consumes search highlight state after navigation and retains it for the
selected message while clearing it on community context changes.

**desktop/src/app/routes/channels.$channelId.posts.$postId.tsx**
Validates and forwards forum search-navigation IDs.

**desktop/src/app/routes/channels.$channelId.tsx**
Validates and forwards channel search-navigation IDs.

**desktop/src/features/channels/ui/ChannelPane.tsx**
Passes the selected result ID and query into the main timeline and open
thread panel.

**desktop/src/features/channels/ui/ChannelPane.types.ts**
Defines the destination-highlight contract for channel panes.

**desktop/src/features/channels/ui/ChannelScreen.tsx**
Routes destination highlighting to either forum content or the channel
timeline.

**desktop/src/features/channels/ui/ChannelScreen.types.ts**
Defines route-level search-highlight inputs.

**desktop/src/features/channels/ui/ForumChannelContent.tsx**
Forwards search context into expanded forum threads.

**desktop/src/features/forum/ui/ForumThreadPanel.tsx**
Highlights the matching text in the selected forum post or reply.

**desktop/src/features/forum/ui/ForumView.tsx**
Carries selected-result context from forum routing into the thread
panel.

**desktop/src/features/messages/ui/DiffMessage.tsx**
Forwards the query into diff descriptions and rendered diff content.

**desktop/src/features/messages/ui/DiffViewer.tsx**
Provides a highlighted match excerpt for structured diffs and highlights
fallback raw diff text.

**desktop/src/features/messages/ui/MessageRow.tsx**
Passes destination queries into normal Markdown, diff, and wave message
renderers.

**desktop/src/features/messages/ui/MessageThreadPanel.tsx**
Applies highlighting to the selected thread head or reply without
tinting unrelated messages.

**desktop/src/features/messages/ui/WaveMessageAttachment.tsx**
Highlights matches in wave-message fallback text.

**desktop/src/features/search/lib/searchMatch.test.mjs**
Covers case-insensitive, literal, multi-term, prefix, one-character, and
late-preview matches.

**desktop/src/features/search/lib/searchMatch.ts**
Centralizes token extraction, case-insensitive match splitting, and
match-focused preview generation.

**desktop/src/features/search/ui/HighlightedSearchText.tsx**
Adds the reusable accessible mark renderer used by result and
specialized message surfaces.

**desktop/src/features/search/ui/TopbarSearch.tsx**
Highlights result previews, centers excerpts around matches, strips
search operators, and hides stale results during debounce transitions.

**desktop/src/features/sidebar/ui/AppSidebar.types.ts**
Updates the sidebar search callback contract to include the result
query.

**desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx**
Passes query-aware result selection through the pinned search entry
point.

**desktop/src/shared/lib/rehypeSearchHighlight.ts**
Uses the shared matcher to mark every matching Markdown text segment
while leaving code blocks untouched.

**desktop/src/shared/lib/searchHighlightStyle.ts**
Defines one yellow highlight treatment for light and dark themes.

**desktop/src/shared/ui/markdown/nodeCache.test.mjs**
Verifies all Markdown matches, one-character scoped searches, code
exclusion, and transient cache behavior.

**desktop/src/shared/ui/markdown/nodeCache.ts**
Enables transient highlighting for one-character scoped searches without
polluting the Markdown cache.

**desktop/tests/e2e/smoke.spec.ts**
Exercises result and destination highlighting, same-route forum
activation, and stale-query suppression end to end.

</details>

## Reproduction steps

1. Open a channel containing a longer message and press Cmd+F.
2. Search for a word that appears later in the message, such as
`mentions`.
3. Confirm each matching result shows the word in yellow and keeps the
match visible in its preview.
4. Open a result and confirm the same term is highlighted in yellow in
the destination message.
5. Repeat with a thread reply or forum post, and quickly change the
query to confirm stale results are not selectable.

## Demo

- **Before:** Search results returned the correct message but offered no
visual indication of where the query matched; opening the message also
showed no match treatment.
- **After:** Every matching term is marked in yellow in the result
preview and in the exact channel, thread, or forum message opened from
that result.

---------

Signed-off-by: tulsi <tulsi@block.xyz>
2026-08-25 16:22:12 +00:00

254 lines
8.1 KiB
JavaScript

import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
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("leading inline content is inserted into the first prose-capable block", () => {
const components = {
span: ({ children, node: _node, ...props }) =>
"data-leading-inline-content" in props
? React.createElement(
"button",
{ "data-chip": "", type: "button" },
"00:01",
)
: React.createElement("span", props, children),
};
for (const [content, pattern] of [
["plain note", /<p><button[^>]*>00:01<\/button>plain note<\/p>/],
["> quoted note", /<blockquote>\s*<p><button[^>]*>00:01<\/button>quoted/],
["- list note", /<li><button[^>]*>00:01<\/button>list note<\/li>/],
["# heading", /<h1><button[^>]*>00:01<\/button>heading<\/h1>/],
]) {
const node = renderCachedMarkdown({
...BASE,
components,
content,
leadingInlineContent: true,
variant: "leading",
});
assert.match(renderToStaticMarkup(node), pattern);
}
});
test("leading inline content falls back before code and media blocks", () => {
const components = {
span: ({ children, node: _node, ...props }) =>
"data-leading-inline-content" in props
? React.createElement(
"button",
{ "data-chip": "", type: "button" },
"00:01",
)
: React.createElement("span", props, children),
};
for (const [content, blockPattern] of [
["```js\nconst answer = 42;\n```", "<pre"],
["![](https://example.com/review.png)", "<img"],
["```js\nconst answer = 42;\n```\n\nlater note", "<pre"],
["![](https://example.com/review.png)\n\nlater note", "<img"],
["> ```js\nconst answer = 42;\n```\n\n> later note", "<pre"],
["> ![](https://example.com/review.png)\n\n> later note", "<img"],
]) {
const node = renderCachedMarkdown({
...BASE,
components,
content,
leadingInlineContent: true,
variant: "leading-fallback",
});
const html = renderToStaticMarkup(node);
assert.match(html, /<p><button[^>]*>00:01<\/button><\/p>/);
assert.ok(html.indexOf("<button") < html.indexOf(blockPattern));
}
});
test("leading inline content stays on the outer tight-list item", () => {
const components = {
span: ({ children, node: _node, ...props }) =>
"data-leading-inline-content" in props
? React.createElement(
"button",
{ "data-chip": "", type: "button" },
"00:01",
)
: React.createElement("span", props, children),
};
const node = renderCachedMarkdown({
...BASE,
components,
content: "- parent\n - child",
leadingInlineContent: true,
variant: "leading-tight-nested-list",
});
assert.match(
renderToStaticMarkup(node),
/<li><button[^>]*>00:01<\/button>parent\s*<ul>\s*<li>child<\/li>/,
);
});
test("leading inline content participates in the cache key", () => {
clearMarkdownNodeCache();
const withoutLeading = renderCachedMarkdown({ ...BASE });
const withLeading = renderCachedMarkdown({
...BASE,
leadingInlineContent: true,
});
assert.notEqual(withoutLeading, withLeading);
});
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("hardLineBreaks changes the parse and the cache key", () => {
clearMarkdownNodeCache();
const content = "hello\nworld";
const withBreaks = renderCachedMarkdown({ ...BASE, content });
const withoutBreaks = renderCachedMarkdown({
...BASE,
content,
hardLineBreaks: false,
});
assert.notEqual(withBreaks, withoutBreaks);
assert.match(renderToStaticMarkup(withBreaks), /<br/i);
assert.doesNotMatch(renderToStaticMarkup(withoutBreaks), /<br/i);
const withoutBreaksAgain = renderCachedMarkdown({
...BASE,
content,
hardLineBreaks: false,
});
assert.equal(withoutBreaks, withoutBreaksAgain);
});
test("single-character scoped search stays on lexeme boundaries", () => {
const html = renderToStaticMarkup(
renderCachedMarkdown({ ...BASE, content: "A plan", searchQuery: "a" }),
);
assert.equal((html.match(/data-search-match="true"/g) ?? []).length, 1);
});
test("active search queries bypass the cache and highlight every match", () => {
clearMarkdownNodeCache();
const input = {
...BASE,
content: "Bold and bold, but not code `bold`.",
searchQuery: "bold",
};
const first = renderCachedMarkdown(input);
const second = renderCachedMarkdown(input);
const html = renderToStaticMarkup(first);
assert.notEqual(first, second);
assert.equal((html.match(/data-search-match="true"/g) ?? []).length, 2);
assert.match(html, /bg-yellow-300/);
assert.match(html, /<code>bold<\/code>/);
});