fix: debounce composer pattern detection to reduce input lag (#222)

This commit is contained in:
Wes
2026-04-03 12:17:37 -07:00
committed by GitHub
parent ddd54a6e84
commit b2762d3253
3 changed files with 148 additions and 40 deletions
@@ -23,6 +23,8 @@ function detectChannelQuery(
return { query, startIndex };
}
const CHANNEL_QUERY_DEBOUNCE_MS = 120;
export function useChannelLinks() {
const { channels } = useChannelNavigation();
@@ -30,6 +32,21 @@ export function useChannelLinks() {
const [channelStartIndex, setChannelStartIndex] = React.useState(0);
const [channelSelectedIndex, setChannelSelectedIndex] = React.useState(0);
const debounceTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const latestValueRef = React.useRef<string>("");
const latestCursorRef = React.useRef<number>(0);
// Clean up pending timeout on unmount
React.useEffect(() => {
return () => {
if (debounceTimerRef.current !== null) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
const channelSuggestions = React.useMemo<ChannelSuggestion[]>(() => {
if (channelQuery === null) {
return [];
@@ -57,6 +74,12 @@ export function useChannelLinks() {
content: string,
selectionEnd: number,
): { nextContent: string; nextCursor: number } => {
// Cancel any pending debounced detection — user already selected
if (debounceTimerRef.current !== null) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
const before = content.slice(0, channelStartIndex);
const after = content.slice(selectionEnd);
const inserted = `#${suggestion.name} `;
@@ -73,19 +96,37 @@ export function useChannelLinks() {
const updateChannelQuery = React.useCallback(
(value: string, cursorPosition: number) => {
const channel = detectChannelQuery(value, cursorPosition);
if (channel) {
setChannelQuery(channel.query);
setChannelStartIndex(channel.startIndex);
setChannelSelectedIndex(0);
} else {
setChannelQuery(null);
// Store latest values so the debounced callback always uses fresh data
latestValueRef.current = value;
latestCursorRef.current = cursorPosition;
if (debounceTimerRef.current !== null) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
debounceTimerRef.current = null;
const channel = detectChannelQuery(
latestValueRef.current,
latestCursorRef.current,
);
if (channel) {
setChannelQuery(channel.query);
setChannelStartIndex(channel.startIndex);
setChannelSelectedIndex(0);
} else {
setChannelQuery(null);
}
}, CHANNEL_QUERY_DEBOUNCE_MS);
},
[],
);
const clearChannels = React.useCallback(() => {
if (debounceTimerRef.current !== null) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
setChannelQuery(null);
setChannelSelectedIndex(0);
}, []);
@@ -5,6 +5,8 @@ import { useChannelMembersQuery } from "@/features/channels/hooks";
import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete";
import { escapeRegExp } from "@/shared/lib/mentionPattern";
const MENTION_DEBOUNCE_MS = 120;
function detectMentionQuery(
value: string,
cursorPosition: number,
@@ -92,6 +94,28 @@ export function useMentions(channelId: string | null) {
[knownNames],
);
// --- Debounce infrastructure for updateMentionQuery ---
const debounceTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const latestValueRef = React.useRef<string>("");
const latestCursorRef = React.useRef<number>(0);
const knownNamesLowerRef = React.useRef<string[]>(knownNamesLower);
// Keep the known-names ref in sync so the debounced callback never reads stale data.
React.useEffect(() => {
knownNamesLowerRef.current = knownNamesLower;
}, [knownNamesLower]);
// Clean up any pending debounce timer on unmount.
React.useEffect(() => {
return () => {
if (debounceTimerRef.current !== null) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
const suggestions = React.useMemo<MentionSuggestion[]>(() => {
if (mentionQuery === null) {
return [];
@@ -130,6 +154,12 @@ export function useMentions(channelId: string | null) {
content: string,
selectionEnd: number,
): { nextContent: string; nextCursor: number } => {
// Cancel any pending debounced detection — user already selected
if (debounceTimerRef.current !== null) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
const displayName = suggestion.displayName;
const before = content.slice(0, mentionStartIndex);
const after = content.slice(selectionEnd);
@@ -148,20 +178,34 @@ export function useMentions(channelId: string | null) {
const updateMentionQuery = React.useCallback(
(value: string, cursorPosition: number) => {
const mention = detectMentionQuery(
value,
cursorPosition,
knownNamesLower,
);
if (mention) {
setMentionQuery(mention.query);
setMentionStartIndex(mention.startIndex);
setMentionSelectedIndex(0);
} else {
setMentionQuery(null);
// Stash the latest values so the debounced callback always uses fresh data.
latestValueRef.current = value;
latestCursorRef.current = cursorPosition;
// Clear any previously scheduled detection.
if (debounceTimerRef.current !== null) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
debounceTimerRef.current = null;
const mention = detectMentionQuery(
latestValueRef.current,
latestCursorRef.current,
knownNamesLowerRef.current,
);
if (mention) {
setMentionQuery(mention.query);
setMentionStartIndex(mention.startIndex);
setMentionSelectedIndex(0);
} else {
setMentionQuery(null);
}
}, MENTION_DEBOUNCE_MS);
},
[knownNamesLower],
// Stable: refs are used inside the timeout, so no reactive deps needed.
[],
);
const extractMentionPubkeys = React.useCallback(
@@ -201,6 +245,10 @@ export function useMentions(channelId: string | null) {
);
const clearMentions = React.useCallback(() => {
if (debounceTimerRef.current !== null) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
mentionMapRef.current.clear();
setMentionQuery(null);
setMentionSelectedIndex(0);
@@ -46,23 +46,33 @@ type ComposerMentionOverlayProps = {
scrollTop: number;
};
export function ComposerMentionOverlay({
content,
mentionNames,
scrollTop,
}: ComposerMentionOverlayProps) {
const pattern = React.useMemo(
() => buildOverlayPattern(mentionNames),
[mentionNames],
);
const segments = parseSegments(content, pattern);
/**
* Overlay that highlights @mentions and #channels in the composer text.
*
* Wrapped in React.memo so it skips re-renders when the parent re-renders
* without changing any of this component's props (e.g. focus state changes).
*/
export const ComposerMentionOverlay = React.memo(
function ComposerMentionOverlay({
content,
mentionNames,
scrollTop,
}: ComposerMentionOverlayProps) {
const pattern = React.useMemo(
() => buildOverlayPattern(mentionNames),
[mentionNames],
);
// Memoize regex parsing so it only re-runs when content or the mention
// pattern actually changes — avoids expensive matchAll on every render.
const segments = React.useMemo(
() => parseSegments(content, pattern),
[content, pattern],
);
return (
<div
className="whitespace-pre-wrap break-words px-0 py-0 text-sm leading-6"
style={{ transform: `translateY(-${scrollTop}px)` }}
>
{
// Memoize the rendered nodes so we don't rebuild the element array unless
// the parsed segments change.
const renderedNodes = React.useMemo(
() =>
segments.reduce<{ offset: number; nodes: React.ReactNode[] }>(
(acc, segment) => {
const key = `${acc.offset}`;
@@ -86,8 +96,17 @@ export function ComposerMentionOverlay({
return acc;
},
{ offset: 0, nodes: [] },
).nodes
}
</div>
);
}
).nodes,
[segments],
);
return (
<div
className="whitespace-pre-wrap break-words px-0 py-0 text-sm leading-6"
style={{ transform: `translateY(-${scrollTop}px)` }}
>
{renderedNodes}
</div>
);
},
);