mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix: debounce composer pattern detection to reduce input lag (#222)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user