Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 36 additions & 26 deletions src/components/editor-input/editor-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import MentionPlugin, {
import MentionNode from './mention-plugin/mention-node';

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What: Ensure that the memoization of initialConfig does not lead to stale values due to dependencies not being included.

Why: Memoization can cause the configuration to retain old values if dependencies are not carefully managed, which could lead to unexpected behavior in the component.

How: Ensure that all relevant state and props that could change and are used in initialConfig are included in the dependency array of useMemo.

import editorTheme from './editor-theme';
import EditorPlaceholder from './editor-placeholder';
import { forwardRef, isValidElement } from 'react';
import { forwardRef, isValidElement, useMemo, useCallback } from 'react';
import OverrideEditorStyle from './override-editor-style-plugin';
import CharacterLimit from './character-limit-plugin';
import type { EditorState, LexicalEditor } from 'lexical';
Expand Down Expand Up @@ -119,33 +119,43 @@ const EditorInput = forwardRef<LexicalEditor, EditorInputProps>(
}: EditorInputProps,
ref: Ref
) => {
const initialConfig = {
namespace: 'Editor',
editorTheme,
onError,
nodes: [ MentionNode ],
editorState: defaultValue ? defaultValue : EMPTY_CONTENT,
editable: disabled ? false : true,
};
// Memoize initial config to prevent re-initialization
const initialConfig = useMemo(
() => ( {
namespace: 'Editor',
editorTheme,
onError,
nodes: [ MentionNode ],
editorState: defaultValue ? defaultValue : EMPTY_CONTENT,
editable: ! disabled,
} ),
[ defaultValue, disabled ]
);

const handleOnChange = (
editorState: EditorState,
editor: LexicalEditor
) => {
if ( typeof onChange !== 'function' ) {
return;
}
onChange( editorState, editor );
};
// Memoize onChange handler to prevent unnecessary re-renders
const handleOnChange = useCallback(
( editorState: EditorState, editor: LexicalEditor ) => {
if ( typeof onChange !== 'function' ) {
return;
}
onChange( editorState, editor );
},
[ onChange ]
);

let menuComponentToUse;
let menuItemComponentToUse;
if ( isValidElement( menuComponent ) ) {
menuComponentToUse = menuComponent;
}
if ( isValidElement( menuItemComponent ) ) {
menuItemComponentToUse = menuItemComponent;
}
// Memoize menu components to prevent re-renders
const menuComponentToUse = useMemo(
() => ( isValidElement( menuComponent ) ? menuComponent : undefined ),
[ menuComponent ]
);

const menuItemComponentToUse = useMemo(
() =>
isValidElement( menuItemComponent )
? menuItemComponent
: undefined,
[ menuItemComponent ]
);

return (
<div
Expand Down
8 changes: 6 additions & 2 deletions src/components/editor-input/editor-placeholder.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { memo } from 'react';

interface EditorPlaceHolder {
/** Placeholder content. */
content: string | React.ReactNode;
}

const EditorPlaceholder = ( { content }: EditorPlaceHolder ) => (
const EditorPlaceholder = memo( ( { content }: EditorPlaceHolder ) => (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 flex items-center justify-start text-field-placeholder w-full"
>
<span className="truncate">{ content }</span>
</div>
);
) );

EditorPlaceholder.displayName = 'EditorPlaceholder';

export default EditorPlaceholder;
42 changes: 20 additions & 22 deletions src/components/editor-input/mention-plugin/mention-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,29 +122,27 @@ const lookupService = {
: string
: string
) {
setTimeout( () => {
if ( ! Array.isArray( options ) ) {
return [];
}
const results = options.filter(
( mention: string | Record<string, unknown> ) => {
if ( typeof mention === 'string' ) {
return mention
.toLowerCase()
.includes( string.toLowerCase() );
}

const strValue = mention?.[ by ]?.toString();
if ( ! strValue ) {
return false;
}
return strValue
.toLowerCase()
.includes( string.toLowerCase() );
// Remove setTimeout for immediate response - debouncing should happen at input level if needed
if ( ! Array.isArray( options ) ) {
callback( [] as T );
return;
}

const lowerString = string.toLowerCase();
const results = options.filter(
( mention: string | Record<string, unknown> ) => {
if ( typeof mention === 'string' ) {
return mention.toLowerCase().includes( lowerString );
}
);
callback( results as T );
}, 500 );

const strValue = mention?.[ by ]?.toString();
if ( ! strValue ) {
return false;
}
return strValue.toLowerCase().includes( lowerString );
}
);
callback( results as T );
},
};

Expand Down
98 changes: 52 additions & 46 deletions src/components/editor-input/mention-plugin/mention-plugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,60 +78,66 @@

const autoSpaceTempOff = useRef( false );
const menuRef = useRef<HTMLElement | null>( null );
// Define PUNCTUATION and other necessary variables inside the component
const PUNCTUATION =
'\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;';

const TRIGGERS = [ trigger ].join( '' ); // Use the trigger prop dynamically
// Memoize regex patterns to prevent recompilation on every render
const checkForAtSignMentions = useMemo( () => {
// Define PUNCTUATION and other necessary variables inside the component
const PUNCTUATION =
'\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;';

const VALID_CHARS = '[^' + TRIGGERS + PUNCTUATION + '\\s]';
const TRIGGERS = [ trigger ].join( '' ); // Use the trigger prop dynamically

const VALID_JOINS =
'(?:' +
'\\.[ |$]|' + // E.g. "r. " in "Mr. Smith"
' |' + // E.g. " " in "Josh Duck"
'[' +
PUNCTUATION +
']|' + // E.g. "-' in "Salier-Hellendag"
')';
const VALID_CHARS = '[^' + TRIGGERS + PUNCTUATION + '\\s]';

const LENGTH_LIMIT = 75;
const VALID_JOINS =
'(?:' +
'\\.[ |$]|' + // E.g. "r. " in "Mr. Smith"
' |' + // E.g. " " in "Josh Duck"
'[' +
PUNCTUATION +
']|' + // E.g. "-' in "Salier-Hellendag"
')';

const AtSignMentionsRegex = new RegExp(
`(^|\\s|\\()([${ TRIGGERS }]((?:${ VALID_CHARS }${ VALID_JOINS }){0,${ LENGTH_LIMIT }}))$`
);
const LENGTH_LIMIT = 75;

// 50 is the longest alias length limit
const ALIAS_LENGTH_LIMIT = 50;
const AtSignMentionsRegex = new RegExp(
`(^|\\s|\\()([${ TRIGGERS }]((?:${ VALID_CHARS }${ VALID_JOINS }){0,${ LENGTH_LIMIT }}))$`
);

// Regex used to match alias
const AtSignMentionsRegexAliasRegex = new RegExp(
`(^|\\s|\\()([${ TRIGGERS }]((?:${ VALID_CHARS }){0,${ ALIAS_LENGTH_LIMIT }}))$`
);
// 50 is the longest alias length limit
const ALIAS_LENGTH_LIMIT = 50;

// Define checkForAtSignMentions function inside the component where it has access to the regex
const checkForAtSignMentions = ( text: string ) => {
let match = AtSignMentionsRegex.exec( text );
// Regex used to match alias
const AtSignMentionsRegexAliasRegex = new RegExp(
`(^|\\s|\\()([${ TRIGGERS }]((?:${ VALID_CHARS }){0,${ ALIAS_LENGTH_LIMIT }}))$`
);

if ( match === null ) {
match = AtSignMentionsRegexAliasRegex.exec( text );
}
if ( match !== null ) {
// The strategy ignores leading whitespace but we need to know its
// length to add it to the leadOffset
const maybeLeadingWhitespace = match[ 1 ];

const matchingString = match[ 3 ];
if ( matchingString.length >= 0 ) {
return {
leadOffset: match.index + maybeLeadingWhitespace.length,
matchingString,
replaceableString: match[ 2 ],
};
// Define checkForAtSignMentions function inside the component where it has access to the regex
const checkForAtSignMentions = ( text: string ) => {

Check failure on line 116 in src/components/editor-input/mention-plugin/mention-plugin.tsx

View workflow job for this annotation

GitHub Actions / analysis (18.15)

'checkForAtSignMentions' is already declared in the upper scope on line 83 column 8
let match = AtSignMentionsRegex.exec( text );

if ( match === null ) {
match = AtSignMentionsRegexAliasRegex.exec( text );
}
}
return null;
};
if ( match !== null ) {
// The strategy ignores leading whitespace but we need to know its
// length to add it to the leadOffset
const maybeLeadingWhitespace = match[ 1 ];

const matchingString = match[ 3 ];
if ( matchingString.length >= 0 ) {
return {
leadOffset: match.index + maybeLeadingWhitespace.length,
matchingString,
replaceableString: match[ 2 ],
};
}
}
return null;
};

return checkForAtSignMentions;
}, [ trigger ] );

const [ editor ] = useLexicalComposerContext();
const [ queryString, setQueryString ] = useState<string | null>( null );
Expand Down Expand Up @@ -159,12 +165,12 @@
setIsMenuOpen( false );
} );
},
[ editor ]
[ editor, by, size ]
);

const options = useMemo( () => {
return results.map( ( result ) => new OptionItem( result ) );
}, [ editor, results ] );
}, [ results ] );

const handleAutoSpaceAfterMention = useCallback<
CommandListener<KeyboardEvent>
Expand Down
Loading