Skip to content

Migrate koenig-lexical to TypeScript#1807

Open
kevinansfield wants to merge 2 commits intomainfrom
ts/koenig-lexical-cleanup
Open

Migrate koenig-lexical to TypeScript#1807
kevinansfield wants to merge 2 commits intomainfrom
ts/koenig-lexical-cleanup

Conversation

@kevinansfield
Copy link
Member

@kevinansfield kevinansfield commented Mar 23, 2026

Summary

  • Rename all .js/.jsx files to .ts/.tsx across src/, test/, and demo/
  • Add TypeScript configuration (tsconfig.json, eslint.config.js with @typescript-eslint)
  • Add type annotations throughout: component props, hooks, utility functions, Lexical node types
  • Replace deprecated tseslint.config() with defineConfig() from eslint/config
  • Create proper interfaces for untyped dependencies (Emoji for emoji-mart, SimpleMDEInstance/CodeMirrorInstance for kg-simplemde)
  • Add test/types.d.ts with Window augmentation for Lexical editor in e2e browser context
  • Remove all eslint-disable comments except intentional react-hooks/exhaustive-deps for mount-only effects

@coderabbitai
Copy link

coderabbitai bot commented Mar 23, 2026

Walkthrough

The PR converts large parts of the koenig-lexical package and demo to stricter TypeScript: adding interfaces and prop typings across components, hooks, nodes, plugins, and contexts; replacing runtime PropTypes with compile-time types; introducing null-safety guards and casts; and tightening event/ref signatures. It adds new/rewrites modules (TypeScript CodeBlockCard, UnsplashModal, TransistorCard, many UI components), removes some JS/PropTypes files, and updates Storybook stories with ts-nocheck. Other notable changes include ESLint config updates, package.json script/dependency edits, Playwright config tweaks, and DragDropReorder ghost rendering switched to renderToString.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ts/koenig-lexical-cleanup

@kevinansfield kevinansfield force-pushed the ts/koenig-lexical-cleanup branch from d5269fd to 9d91044 Compare March 23, 2026 19:37
@kevinansfield kevinansfield changed the title Remove any types and eslint-disable from koenig-lexical Migrate koenig-lexical to TypeScript Mar 23, 2026
@kevinansfield kevinansfield changed the base branch from typescript-migration to main March 23, 2026 19:37
@kevinansfield kevinansfield force-pushed the ts/koenig-lexical-cleanup branch 2 times, most recently from 4e6b80d to 5516184 Compare March 23, 2026 19:53
@kevinansfield kevinansfield force-pushed the ts/koenig-lexical-cleanup branch from 5516184 to c91da8a Compare March 23, 2026 19:58
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (23)
packages/koenig-lexical/demo/components/Navigator.tsx (1)

9-16: ⚠️ Potential issue | 🟠 Major

Wrap the side effect in useEffect and add cleanup.

The global window mutation on line 13 runs in the component body, which executes on every render. React side effects should be placed in useEffect to:

  • Run only when dependencies change (mount/navigate updates)
  • Enable proper cleanup on unmount to avoid stale references
🔧 Proposed fix using useEffect with cleanup
+import {useEffect} from 'react';
 import {useNavigate} from 'react-router-dom';

 declare global {
     interface Window {
         navigate: ReturnType<typeof useNavigate>;
     }
 }

 const Navigator = () => {
     const navigate = useNavigate();

-    // Hack, used to allow Playwright to navigate without triggering a full page reload.
-    window.navigate = navigate;
+    useEffect(() => {
+        // Hack, used to allow Playwright to navigate without triggering a full page reload.
+        window.navigate = navigate;
+
+        return () => {
+            delete window.navigate;
+        };
+    }, [navigate]);

     return null;
 };

 export default Navigator;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/demo/components/Navigator.tsx` around lines 9 - 16,
The Navigator component assigns window.navigate in the render body which runs
every render; move that side effect into a useEffect inside the Navigator
function so the assignment runs only when the navigate function changes (use
[navigate] as dependency), perform a safety check for window (typeof window !==
'undefined') and add cleanup in the effect to remove the global (either delete
window.navigate or set it to undefined) on unmount to avoid stale references.
packages/koenig-lexical/src/hooks/usePinturaEditor.ts (1)

166-169: ⚠️ Potential issue | 🔴 Critical

Event listener not properly removed due to mismatched capture option.

The listener is added with {capture: true} at line 166, but removed without it at line 169. The capture flag must match for removeEventListener to find and remove the correct listener. This causes a memory leak where handlers accumulate on each component remount.

🐛 Proposed fix
         return () => {
-            window.removeEventListener('click', handleCloseClick);
+            window.removeEventListener('click', handleCloseClick, {capture: true});
         };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/hooks/usePinturaEditor.ts` around lines 166 -
169, The event listener added in usePinturaEditor uses
window.addEventListener('click', handleCloseClick, {capture: true}) but is
removed without the same options, so removeEventListener won't match; update the
cleanup in the usePinturaEditor hook to call window.removeEventListener('click',
handleCloseClick, {capture: true}) (matching the addEventListener options) so
the handleCloseClick listener is correctly removed on unmount/remount.
packages/koenig-lexical/src/components/ui/TenorSelector.tsx (1)

74-80: ⚠️ Potential issue | 🟠 Major

handleGifHighlight referenced before definition causes stale closure.

The useEffect on lines 74-80 references handleGifHighlight in its dependency array, but handleGifHighlight is defined later (line 225) and recreated on every render. This can cause the event listener to reference stale state.

Wrap handleGifHighlight in useCallback or move before the effect
+    const handleGifHighlight = React.useCallback((event: KeyboardEvent) => {
+        switch (event.key) {
+        case 'Tab':
+            return handleTab(event);
+        // ... rest of cases
+        }
+    }, [highlightedGif, gifs, columns]);
+
     useEffect(() => {
         document.addEventListener('keydown', handleGifHighlight);

         return () => {
             document.removeEventListener('keydown', handleGifHighlight);
         };
     }, [handleGifHighlight]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/ui/TenorSelector.tsx` around lines 74
- 80, The effect that adds/removes the keydown listener references
handleGifHighlight which is defined later and recreated each render; make
handleGifHighlight stable by wrapping it in useCallback (e.g.,
useCallback(handleGifHighlight, [/* include any state/props it uses */])) or
move its definition above the useEffect so the same function instance is used
for add/remove, and then ensure the useEffect dependency array contains the
stable handleGifHighlight (or the correct dependency list) so the
cleanup/unsubscribe works with the exact same function reference.
packages/koenig-lexical/src/hooks/useTypeaheadTriggerMatch.ts (1)

15-21: ⚠️ Potential issue | 🟠 Major

Escape trigger before interpolating into regex character classes.
Lines 15 and 17 concatenate trigger directly into regex character classes without escaping. Special characters like ], ^, \, - change regex semantics or create invalid patterns. For example, a trigger value of ] would produce invalid syntax, and ^ would negate the character class instead of matching the literal character.

Proposed patch
+const escapeForCharClass = (value: string) => value.replace(/[\\\]\[-^]/g, '\\$&');
+
 export default function useBasicTypeaheadTriggerMatch(trigger: string, {minLength = 1, maxLength = 75}: {minLength?: number; maxLength?: number}) {
     return useCallback(
         (text: string): TriggerMatch | null => {
-            const invalidChars = '[^' + trigger + '\\s]'; // escaped set - these cannot be present in the matched string
+            const safeTrigger = escapeForCharClass(trigger);
+            const invalidChars = `[^${safeTrigger}\\s]`; // escaped set - these cannot be present in the matched string
             const TypeaheadTriggerRegex = new RegExp(
-                '[' + trigger + ']' +
+                '[' + safeTrigger + ']' +
                 '(' +
                     '(?:' + invalidChars + ')' +
                     '{0,' + maxLength + '}' +
                 ')$',
             );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/hooks/useTypeaheadTriggerMatch.ts` around lines
15 - 21, The code builds character classes using the raw trigger string (see
invalidChars and TypeaheadTriggerRegex) which breaks for metacharacters like ],
^, -, \; fix by escaping regex-special characters in trigger before
interpolating it into the character-class expressions (create an escapeRegExp
helper or reuse one, produce escapedTrigger = escapeRegExp(trigger)), then use
escapedTrigger when constructing invalidChars and the TypeaheadTriggerRegex so
the trigger is always treated as a literal character in the regex.
packages/koenig-lexical/src/components/ui/cards/MarkdownCard/MarkdownEditor.tsx (1)

163-175: ⚠️ Potential issue | 🟡 Minor

Non-null assertions throughout assume editor is always initialized.

Functions like addShortcuts, toggleSpellcheck, toggleButtonClass, closeHelpDialog, and closeUnsplashDialog all use markdownEditor.current!. If any of these are called after unmount (e.g., during async cleanup or event handler race conditions), they will throw.

Consider adding a guard at the top of each function, or creating a helper that safely accesses the editor:

🛡️ Example helper approach
function getEditor(): SimpleMDEInstance | null {
    return markdownEditor.current;
}

function addShortcuts() {
    const editor = getEditor();
    if (!editor) return;
    const codemirror = editor.codemirror;
    // ...
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/koenig-lexical/src/components/ui/cards/MarkdownCard/MarkdownEditor.tsx`
around lines 163 - 175, Several functions (addShortcuts, toggleSpellcheck,
toggleButtonClass, closeHelpDialog, closeUnsplashDialog) directly use
markdownEditor.current! and can throw if the editor is unmounted; create a small
safe accessor (e.g., getEditor() that returns markdownEditor.current or null)
and at the top of each affected function use const editor = getEditor(); if
(!editor) return; then use editor.codemirror instead of markdownEditor.current!
and update calls like codemirror.setOption('extraKeys', ...) to operate only
when editor is present so no non-null assertions remain.
packages/koenig-lexical/src/components/ui/cards/MarkdownCard/useMarkdownImageUploader.ts (1)

53-88: ⚠️ Potential issue | 🟡 Minor

Non-null assertions on editor.current may cause runtime errors.

Lines 54 and 83 use editor.current! to assert the editor is non-null, but SimpleMDEEditorRef explicitly types current as { ... } | null. If insertImages is called before the editor initializes or after it unmounts, this will throw a runtime exception.

Consider adding an early return guard or using optional chaining with appropriate fallback behavior:

🛡️ Proposed guard
 function insertImages(urls: (string | UploadResult)[] = []) {
+    if (!editor.current) {
+        return;
+    }
-    const codemirror = editor.current!.codemirror;
+    const codemirror = editor.current.codemirror;
     // ... rest of function
-    editor.current!.codemirror.focus();
+    editor.current.codemirror.focus();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/koenig-lexical/src/components/ui/cards/MarkdownCard/useMarkdownImageUploader.ts`
around lines 53 - 88, The insertImages function uses non-null assertions on
editor.current (SimpleMDEEditorRef) and then calls
editor.current.codemirror.focus() and codemirror.replaceSelection(...), which
can throw if the ref is null; update insertImages to early-return when
editor.current is null (e.g., const ed = editor.current; if (!ed) return) and
then use the local ed.codemirror safely (or optional chaining) before calling
focus() and replaceSelection to avoid runtime exceptions when the editor is not
initialized or has unmounted.
packages/koenig-lexical/src/components/ui/Modal.tsx (1)

12-17: ⚠️ Potential issue | 🟠 Major

Move preventDefault() and stopPropagation() inside the Escape key condition

Calling preventDefault() on all keydown events (lines 13–14) blocks keyboard interaction with any interactive content inside the modal. Restrict these calls to Escape key handling only.

Proposed fix
    const controlByKeys = (event: React.KeyboardEvent) => {
-       event.stopPropagation();
-       event.preventDefault();
-
        if (event.key === 'Escape') {
+           event.stopPropagation();
+           event.preventDefault();
            onClose();
        }
    };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/ui/Modal.tsx` around lines 12 - 17, In
controlByKeys, stop calling event.preventDefault() and event.stopPropagation()
for every key; instead, detect if event.key === 'Escape' and then call
event.preventDefault() and event.stopPropagation() immediately before invoking
onClose(); leave all other keys untouched so interactive content inside the
modal can receive normal keyboard events. Ensure the change is made inside the
controlByKeys function and keeps the onClose() call tied to the Escape branch.
packages/koenig-lexical/src/components/ui/Button.tsx (1)

49-49: ⚠️ Potential issue | 🟠 Major

Logic bug: width === 'full' never applies w-full class.

The expression width === 'regular' || 'w-full' is always truthy:

  • When width === 'regular': evaluates to true
  • When width === 'full': evaluates to 'w-full' (truthy string)

This means the w-full class is always included, regardless of the width prop value. The intended logic appears to be applying w-full only when width === 'full'.

Proposed fix
 className={clsx(
     'not-kg-prose inline-block cursor-pointer text-center font-sans font-medium',
     (!shrink && 'shrink-0'),
-    width === 'regular' || 'w-full',
+    width === 'full' && 'w-full',
     rounded && 'rounded-md',
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/ui/Button.tsx` at line 49, The class
expression in Button.tsx mistakenly uses "width === 'regular' || 'w-full'",
which always evaluates truthy and thus always adds "w-full"; update the
conditional so "w-full" is only added when the width prop equals 'full' (e.g.,
replace that part with a conditional that checks width === 'full' before adding
'w-full'). Locate the Button component and the className/array where the width
variable is used and change the faulty expression to a proper conditional that
only includes 'w-full' when width === 'full'.
packages/koenig-lexical/src/plugins/HeaderPlugin.tsx (1)

11-28: ⚠️ Potential issue | 🟠 Major

Add useEffect dependency array to prevent re-registering command on every render.

The useEffect at lines 11-28 is missing a dependency array, causing it to re-run on every render and repeatedly register/unregister the command handler. Add [editor] as the dependency array to match the pattern used in other similar plugins.

💡 Proposed fix
-    });
+    }, [editor]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/plugins/HeaderPlugin.tsx` around lines 11 - 28,
The useEffect in HeaderPlugin (the effect that checks
editor.hasNodes(HeaderNode) and registers INSERT_HEADER_COMMAND via
editor.registerCommand and mergeRegister) is missing a dependency array and
therefore re-registers on every render; update that useEffect to include
[editor] as its dependency so the registration/unregistration runs only when the
editor instance changes, retaining the existing logic that creates the card node
via $createHeaderNode and dispatches INSERT_CARD_COMMAND with
COMMAND_PRIORITY_LOW.
packages/koenig-lexical/src/plugins/CalloutPlugin.tsx (1)

11-28: ⚠️ Potential issue | 🟠 Major

useEffect is missing the [editor] dependency array.

At line 28, the effect closes without a dependency array, causing the command registration to re-run on every render. Comparable plugin files (EmbedPlugin, HtmlPlugin) include [editor] as a dependency since the effect uses the editor object.

Proposed fix
-    });
+    }, [editor]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/plugins/CalloutPlugin.tsx` around lines 11 - 28,
The useEffect in CalloutPlugin is missing its dependency array which causes the
command registration to run on every render; update the React.useEffect call
that checks editor.hasNodes([CalloutNode]) and registers the
INSERT_CALLOUT_COMMAND to include [editor] as its dependency so the effect only
re-runs when the editor instance changes, ensuring proper lifecycle for
editor.registerCommand and avoiding duplicate registrations for
INSERT_CALLOUT_COMMAND, the $createCalloutNode call, and editor.dispatchCommand.
packages/koenig-lexical/src/plugins/FilePlugin.tsx (1)

11-28: ⚠️ Potential issue | 🟠 Major

Add a dependency array to avoid repeated command re-registration.

At line 11, React.useEffect runs after every render because it has no dependency list, which repeatedly tears down and re-registers INSERT_FILE_COMMAND. Properly implemented plugins like AudioPlugin use [editor] as the dependency array.

💡 Proposed fix
-    }, [editor]);
+    }, [editor]);
     });

Change line 28 from }); to }, [editor]);

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/plugins/FilePlugin.tsx` around lines 11 - 28, The
useEffect in FilePlugin re-registers INSERT_FILE_COMMAND on every render because
it has no dependency array; update the React.useEffect call that checks
editor.hasNodes(FileNode) and registers the command (the block using
mergeRegister, editor.registerCommand, INSERT_FILE_COMMAND, INSERT_CARD_COMMAND
and $createFileNode) to include [editor] as its dependency array so the
registration is only created/cleaned when the editor instance changes.
packages/koenig-lexical/src/plugins/PaywallPlugin.tsx (1)

11-14: ⚠️ Potential issue | 🟠 Major

Bug: hasNodes([]) with empty array doesn't validate PaywallNode registration.

The check passes an empty array, so it always returns true and never logs the error. This should check for PaywallNode.

🐛 Proposed fix
+import {$createPaywallNode, INSERT_PAYWALL_COMMAND, PaywallNode} from '../nodes/PaywallNode';
-import {$createPaywallNode, INSERT_PAYWALL_COMMAND} from '../nodes/PaywallNode';
     useEffect(() => {
-        if (!editor.hasNodes([])) {
+        if (!editor.hasNodes([PaywallNode])) {
             console.error('PaywallPlugin: PaywallNode not registered');
             return;
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/plugins/PaywallPlugin.tsx` around lines 11 - 14,
The current check in PaywallPlugin uses editor.hasNodes([]) which always
succeeds; change the validation to explicitly check for the PaywallNode type
(e.g., use editor.hasNodes([PaywallNode]) or the correct API form) and ensure
PaywallNode is imported/available, so the console.error('PaywallPlugin:
PaywallNode not registered') path is reachable when the node is missing; update
the condition in the PaywallPlugin initialization where editor.hasNodes([]) is
called.
packages/koenig-lexical/src/plugins/KoenigFocusPlugin.tsx (1)

7-16: ⚠️ Potential issue | 🔴 Critical

Missing cleanup: registerCommand returns an unsubscribe function that must be returned from useEffect.

The command listener is never unregistered, causing a memory leak. Each time onFocus changes, a new listener is added without removing the previous one.

🐛 Proposed fix
     useEffect(() => {
-        editor.registerCommand(
+        return editor.registerCommand(
             FOCUS_COMMAND,
             () => {
                 onFocus?.();
                 return false;
             },
             COMMAND_PRIORITY_EDITOR
         );
     }, [editor, onFocus]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/plugins/KoenigFocusPlugin.tsx` around lines 7 -
16, The useEffect registering the FOCUS_COMMAND currently never unsubscribes;
capture the unsubscribe returned by editor.registerCommand inside the useEffect
and return it as the cleanup function so the listener is removed when onFocus or
editor changes. Specifically, update the useEffect that calls
editor.registerCommand(FOCUS_COMMAND, ... , COMMAND_PRIORITY_EDITOR) to store
the returned disposer and return that disposer (or a function that calls it) so
previous registrations are cleaned up when onFocus updates.
packages/koenig-lexical/src/plugins/HtmlOutputPlugin.tsx (1)

49-53: ⚠️ Potential issue | 🟡 Minor

Inconsistent optional chaining on setHtml.

Line 50 uses setHtml?.(htmlString) with optional chaining, but line 52 calls setHtml('') directly. Since setHtml is typed as required (setHtml: (html: string) => void), the optional chaining on line 50 is unnecessary.

For consistency, either:

  1. Remove the optional chaining since setHtml is required, or
  2. If setHtml should be optional, update the type and add optional chaining on line 52
Suggested fix (if setHtml remains required)
             if (captionText) {
-                setHtml?.(htmlString);
+                setHtml(htmlString);
             } else {
                 setHtml('');
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/plugins/HtmlOutputPlugin.tsx` around lines 49 -
53, The conditional uses optional chaining on setHtml (setHtml?.(htmlString))
but later calls setHtml('') directly; since setHtml is declared required
(setHtml: (html: string) => void) remove the unnecessary optional chaining and
call setHtml(htmlString) for consistency; alternatively, if setHtml should be
optional, update its type to (html?: string) => void (or setHtml?: (html:
string) => void) and add optional chaining on the empty-string call as well —
locate the usage in HtmlOutputPlugin.tsx around the captionText/htmlString
handling and update the setHtml calls and signature accordingly.
packages/koenig-lexical/src/plugins/FloatingToolbarPlugin.tsx (1)

83-100: ⚠️ Potential issue | 🟠 Major

Return the disposer from registerCommand.

The effect at lines 83-100 registers a command handler but doesn't return the cleanup function from editor.registerCommand(). This causes handlers to accumulate across remounts, especially problematic under React Strict Mode, which intentionally invokes effects twice during development.

💡 Suggested fix
 React.useEffect(() => {
-    editor.registerCommand(
+    return editor.registerCommand(
         KEY_MODIFIER_COMMAND,
         (event: KeyboardEvent) => {
             const {keyCode, ctrlKey, metaKey, shiftKey} = event;
             // ctrl/cmd K with selected text should prompt for link insertion
             if (!shiftKey && keyCode === 75 && (ctrlKey || metaKey)) {
                 const selection = $getSelection();
                 if ($isRangeSelection(selection) && !selection.isCollapsed()) {
                     setToolbarItemType(toolbarItemTypes.link);
                     event.preventDefault();
                     return true;
                 }
             }
             return false;
         },
         COMMAND_PRIORITY_LOW
     );
 }, [editor]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/plugins/FloatingToolbarPlugin.tsx` around lines
83 - 100, The effect registers a command via editor.registerCommand (for
KEY_MODIFIER_COMMAND) but never returns the disposer, causing handlers to
accumulate; update the React.useEffect to capture the disposer returned by
editor.registerCommand and return a cleanup function that calls that disposer
(or unregisters the command) so the handler is removed on unmount/re-run—place
this around the registration for KEY_MODIFIER_COMMAND and ensure
setToolbarItemType usage remains unchanged inside the handler.
packages/koenig-lexical/src/components/ui/UrlSearchInput.tsx (1)

51-58: ⚠️ Potential issue | 🟡 Minor

Don't render a dead “Paste URL as link” action.

Line 57 made the callback optional, but this button still renders for every error state. If a caller omits handlePasteAsLink, users get a visible action that can never do anything.

🩹 Proposed fix
-                    <button className="mr-3 cursor-pointer" data-testid={`${dataTestId}-error-pasteAsLink`} type="button"><span className="font-semibold underline" onClick={() => handlePasteAsLink?.(value)}>Paste URL as link</span></button>
+                    {handlePasteAsLink && (
+                        <button
+                            className="mr-3 cursor-pointer"
+                            data-testid={`${dataTestId}-error-pasteAsLink`}
+                            type="button"
+                            onClick={() => handlePasteAsLink(value)}
+                        >
+                            <span className="font-semibold underline">Paste URL as link</span>
+                        </button>
+                    )}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/ui/UrlSearchInput.tsx` around lines 51
- 58, The error UI renders a non-functional "Paste URL as link" button even when
the optional handler handlePasteAsLink is not provided; update the
UrlSearchInput component so the error-state button (data-testid
`${dataTestId}-error-pasteAsLink`) is only rendered when handlePasteAsLink is
defined (e.g. guard the button with handlePasteAsLink) and preserve the existing
markup/attributes and onClick invocation when present.
packages/koenig-lexical/src/components/ui/cards/VideoCard.tsx (1)

168-176: ⚠️ Potential issue | 🟡 Minor

Incorrect event type cast for form onChange.

Line 168 casts onFileChange (which expects React.ChangeEvent<HTMLInputElement>) to React.FormEventHandler<HTMLFormElement>. These are different event types - form events bubble from child inputs and have HTMLFormElement as currentTarget, not HTMLInputElement.

The pattern of using form onChange to capture file input changes is unconventional. Consider attaching onChange directly to the <input> element instead.

🐛 Proposed fix
-            <form onChange={onFileChange as unknown as React.FormEventHandler<HTMLFormElement>}>
+            <form>
                 <input
                     ref={fileInputRef as React.RefObject<HTMLInputElement>}
                     accept={videoMimeTypes.join(',')}
                     hidden={true}
                     name="image-input"
                     type='file'
+                    onChange={onFileChange}
                 />
             </form>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/ui/cards/VideoCard.tsx` around lines
168 - 176, The form is incorrectly using onChange with a wrong event type cast;
remove the onChange from the <form> and instead attach onFileChange (typed as
React.ChangeEvent<HTMLInputElement>) directly to the <input> (the element
referenced by fileInputRef) so the event currentTarget is an HTMLInputElement
and no unsafe cast is required; update VideoCard.tsx to move the handler to the
input, remove the cast to React.FormEventHandler<HTMLFormElement>, and keep
accept, name, hidden and ref as-is.
packages/koenig-lexical/src/components/ui/LinkInputWithSearch.tsx (1)

65-78: ⚠️ Potential issue | 🟡 Minor

Incorrect type cast for onClick prop.

Line 74 casts onItemSelected as () => void, but onItemSelected has signature (item: {value: string; type?: string}) => void. The cast misrepresents the function signature. If LinkInputSearchItem passes the item when calling onClick, this will work at runtime but the typing is incorrect.

Consider properly typing the LinkInputSearchItem component's onClick prop or using a more accurate cast that reflects the actual callback signature.

♻️ Proposed fix
-                onClick={onItemSelected as () => void}
+                onClick={() => onItemSelected(item)}

Or if LinkInputSearchItem passes the item internally:

-                onClick={onItemSelected as () => void}
+                onClick={onItemSelected as (item: {value: string; label: string}) => void}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/ui/LinkInputWithSearch.tsx` around
lines 65 - 78, The onClick prop is being miscast in getItem; instead of using
onItemSelected as () => void, either adjust LinkInputSearchItem's onClick prop
type to accept (item: {value: string; type?: string}) => void, or change the
call site in getItem to pass a zero-arg wrapper that forwards the item (e.g.,
onClick={() => onItemSelected(item)}), ensuring the signatures for
LinkInputSearchItem.onClick and the onItemSelected callback match; update the
types for LinkInputSearchItem props if you choose the first approach so
TypeScript reflects the actual (item) => void signature.
packages/koenig-lexical/src/components/ui/ColorOptionButtons.tsx (1)

70-74: ⚠️ Potential issue | 🟡 Minor

Accessibility: <li> with onClick lacks keyboard support.

The "background-image" list item has an onClick handler but no keyboard accessibility attributes (role="button", tabIndex, onKeyDown). This makes it inaccessible to keyboard users.

♿ Proposed fix to add keyboard accessibility
-<li key='background-image' className={`mb-0 flex size-[3rem] cursor-pointer items-center justify-center rounded-full border-2 ${selectedName === name ? 'border-green' : 'border-transparent'}`} data-testid="background-image-color-button" onClick={() => onClick(name)}>
+<li key='background-image' className={`mb-0 flex size-[3rem] cursor-pointer items-center justify-center rounded-full border-2 ${selectedName === name ? 'border-green' : 'border-transparent'}`} data-testid="background-image-color-button" role="button" tabIndex={0} onClick={() => onClick(name)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(name); } }}>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/ui/ColorOptionButtons.tsx` around
lines 70 - 74, The list item with key 'background-image' uses an onClick handler
but lacks keyboard accessibility; update that <li> (the element rendering the
background-image option) to include role="button", tabIndex={0}, and an
onKeyDown handler that calls the existing onClick(name) when Enter or Space is
pressed (use the same onClick prop passed into the component), and add an
appropriate aria-label or aria-pressed if needed to convey state (use
selectedName to reflect selection). This will ensure keyboard users can focus
and activate the background-image option the same way mouse users can.
packages/koenig-lexical/src/nodes/ProductNodeComponent.tsx (1)

70-89: ⚠️ Potential issue | 🟠 Major

Always release the preview URL in a finally block.

If getImageDimensions() or imgUploader.upload() rejects, imgPreview never clears and the blob URL is never revoked. Guard the empty-file case up front and move cleanup into finally.

🧹 Safer upload flow
     const handleImgUpload = async (files: File[] | FileList) => {
-        const imgPreviewUrl = URL.createObjectURL(files[0]);
+        const fileArray = Array.from(files);
+        const [file] = fileArray;
+        if (!file) {
+            return;
+        }
+
+        const imgPreviewUrl = URL.createObjectURL(file);
         setImgPreview(imgPreviewUrl);
 
-        const {width, height} = await getImageDimensions(imgPreviewUrl);
-        const imgUploadResult = await imgUploader.upload(Array.from(files));
-        const imageUrl = imgUploadResult?.[0]?.url;
-
-        if (imageUrl) {
-            editor.update(() => {
-                const node = $getProductNodeByKey(nodeKey);
-                if (!node) {return;}
-                node.productImageSrc = imageUrl;
-                node.productImageHeight = height;
-                node.productImageWidth = width;
-            });
+        try {
+            const {width, height} = await getImageDimensions(imgPreviewUrl);
+            const imgUploadResult = await imgUploader.upload(fileArray);
+            const imageUrl = imgUploadResult?.[0]?.url;
+
+            if (imageUrl) {
+                editor.update(() => {
+                    const node = $getProductNodeByKey(nodeKey);
+                    if (!node) {return;}
+                    node.productImageSrc = imageUrl;
+                    node.productImageHeight = height;
+                    node.productImageWidth = width;
+                });
+            }
+        } finally {
+            setImgPreview('');
+            URL.revokeObjectURL(imgPreviewUrl);
         }
-
-        setImgPreview('');
-        URL.revokeObjectURL(imgPreviewUrl);
     };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/nodes/ProductNodeComponent.tsx` around lines 70 -
89, The handleImgUpload function currently sets a blob preview and performs
async work but only clears setImgPreview and calls URL.revokeObjectURL when all
awaits succeed; move the cleanup into a finally block so the preview is always
cleared and the object URL always revoked even if getImageDimensions or
imgUploader.upload throws. Also guard against empty files up front (return early
if files.length === 0) and keep the editor.update block that sets
node.productImageSrc/productImageHeight/productImageWidth via
$getProductNodeByKey inside the try path; ensure you reference handleImgUpload,
getImageDimensions, imgUploader.upload, setImgPreview, URL.revokeObjectURL and
$getProductNodeByKey when making the change.
packages/koenig-lexical/src/components/ui/KeyboardSelectionWithGroups.tsx (1)

41-63: ⚠️ Potential issue | 🟠 Major

Keep Enter consistent with the new hover guard.

Hover now refuses to select items without value, but the Enter path still calls onSelect(items[selectedIndex]) even when the list is empty or the current item is non-selectable.

⌨️ Match the keyboard path to the hover rule
         if (event.key === 'Enter') {
             event.preventDefault();
             event.stopPropagation();
-            onSelect(items[selectedIndex]);
+            const item = items[selectedIndex];
+            if (item?.value) {
+                onSelect(item);
+            }
         }

Also applies to: 81-84

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/ui/KeyboardSelectionWithGroups.tsx`
around lines 41 - 63, In handleKeydown (the callback handling
ArrowUp/ArrowDown/Enter) ensure the Enter path mirrors the hover guard by
verifying the selected index is in range and the targeted item has a selectable
value before calling onSelect; specifically, replace the direct call
onSelect(items[selectedIndex]) with a guard that checks items.length > 0,
selectedIndex >= 0 and < items.length, and that items[selectedIndex].value is
present/truthy, and only then call onSelect; apply the same additional guard
logic to the other Enter-handling occurrence around the code referenced (lines
~81-84) so Enter never selects when the list is empty or the current item is
non-selectable.
packages/koenig-lexical/src/components/ui/cards/CallToActionCard.tsx (2)

417-433: ⚠️ Potential issue | 🟡 Minor

Non-null assertion on htmlEditor may cause runtime error.

Similar to the sponsor label editor, htmlEditor! at line 421 assumes the editor is always provided, but it's typed as optional. The KoenigNestedEditor component likely requires a valid editor instance.

🛡️ Suggested fix: Guard the rendering
-{/* HTML content */}
-<KoenigNestedEditor
+{/* HTML content */}
+{htmlEditor && <KoenigNestedEditor
     autoFocus={true}
     dataTestId={'cta-card-content-editor'}
     hasSettingsPanel={true}
-    initialEditor={htmlEditor!}
+    initialEditor={htmlEditor}
     ...
 >
     <ReplacementStringsPlugin />
-</KoenigNestedEditor>
+</KoenigNestedEditor>}

Or make htmlEditor required in the interface if it's always expected.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/ui/cards/CallToActionCard.tsx` around
lines 417 - 433, The code uses a non-null assertion htmlEditor! when rendering
KoenigNestedEditor which can crash if htmlEditor is undefined; update the render
to guard this usage by only rendering KoenigNestedEditor when htmlEditor is
defined (check htmlEditor before rendering) or alternatively make htmlEditor
required in the component props/interface so callers must always provide it;
ensure related props like htmlEditorInitialState and initialTheme are also only
passed/used when htmlEditor exists to avoid further runtime errors.

370-384: ⚠️ Potential issue | 🟡 Minor

Non-null assertion on sponsorLabelHtmlEditor may cause runtime error.

The assertion sponsorLabelHtmlEditor! at line 374 assumes the editor is always provided when hasSponsorLabel is true. However, the prop is typed as optional (sponsorLabelHtmlEditor?: LexicalEditor). If the parent component passes hasSponsorLabel={true} without providing the editor, this will throw.

Consider adding a guard or making the prop required when hasSponsorLabel is true:

🛡️ Option 1: Add a guard
 {/* Sponsor label */}
-{hasSponsorLabel && (
+{hasSponsorLabel && sponsorLabelHtmlEditor && (
     <div className={clsx(
         'py-3',
         {'mx-6': color !== 'none'}
     )}>
         <KoenigNestedEditor
             autoFocus={true}
             dataTestId={'sponsor-label-editor'}
             hasSettingsPanel={true}
-            initialEditor={sponsorLabelHtmlEditor!}
+            initialEditor={sponsorLabelHtmlEditor}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/ui/cards/CallToActionCard.tsx` around
lines 370 - 384, The code uses a non-null assertion on sponsorLabelHtmlEditor
when rendering KoenigNestedEditor which can throw if hasSponsorLabel is true but
sponsorLabelHtmlEditor is undefined; update the render logic in CallToActionCard
to guard before using sponsorLabelHtmlEditor (and related
sponsorLabelHtmlEditorInitialState) — e.g. only render KoenigNestedEditor when
hasSponsorLabel && sponsorLabelHtmlEditor are truthy, or make
sponsorLabelHtmlEditor a required prop when hasSponsorLabel is true; ensure
RestrictContentPlugin and props (autoFocus, initialEditor, initialEditorState,
initialTheme, textClassName) are passed only after the guard so no runtime null
assertion occurs.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b635288d-adc7-4a80-a19f-c848467913ba

📥 Commits

Reviewing files that changed from the base of the PR and between 713537e and 4e6b80d.

📒 Files selected for processing (300)
  • packages/koenig-lexical/.storybook/main.ts
  • packages/koenig-lexical/demo/DemoApp.tsx
  • packages/koenig-lexical/demo/HtmlOutputDemo.tsx
  • packages/koenig-lexical/demo/RestrictedContentDemo.tsx
  • packages/koenig-lexical/demo/components/DarkModeToggle.tsx
  • packages/koenig-lexical/demo/components/EmailEditorWrapper.tsx
  • packages/koenig-lexical/demo/components/FloatingButton.tsx
  • packages/koenig-lexical/demo/components/InitialContentToggle.tsx
  • packages/koenig-lexical/demo/components/Navigator.tsx
  • packages/koenig-lexical/demo/components/SerializedStateTextarea.tsx
  • packages/koenig-lexical/demo/components/Sidebar.tsx
  • packages/koenig-lexical/demo/components/TitleTextBox.tsx
  • packages/koenig-lexical/demo/components/TreeView.tsx
  • packages/koenig-lexical/demo/components/Watermark.tsx
  • packages/koenig-lexical/demo/components/WordCount.tsx
  • packages/koenig-lexical/demo/demo.tsx
  • packages/koenig-lexical/demo/utils/fetchEmbed.ts
  • packages/koenig-lexical/demo/utils/tenorConfig.ts
  • packages/koenig-lexical/demo/utils/unsplashConfig.ts
  • packages/koenig-lexical/demo/utils/useFileUpload.ts
  • packages/koenig-lexical/demo/utils/useSnippets.ts
  • packages/koenig-lexical/eslint.config.js
  • packages/koenig-lexical/package.json
  • packages/koenig-lexical/playwright.config.ts
  • packages/koenig-lexical/src/components/DesignSandbox.tsx
  • packages/koenig-lexical/src/components/KoenigCaptionEditor.tsx
  • packages/koenig-lexical/src/components/KoenigCardWrapper.tsx
  • packages/koenig-lexical/src/components/KoenigComposableEditor.tsx
  • packages/koenig-lexical/src/components/KoenigComposer.tsx
  • packages/koenig-lexical/src/components/KoenigEditor.tsx
  • packages/koenig-lexical/src/components/KoenigErrorBoundary.tsx
  • packages/koenig-lexical/src/components/KoenigNestedComposer.tsx
  • packages/koenig-lexical/src/components/KoenigNestedEditor.tsx
  • packages/koenig-lexical/src/components/ui/ActionToolbar.tsx
  • packages/koenig-lexical/src/components/ui/AtLinkResultsPopup.tsx
  • packages/koenig-lexical/src/components/ui/AudioUploadForm.jsx
  • packages/koenig-lexical/src/components/ui/AudioUploadForm.tsx
  • packages/koenig-lexical/src/components/ui/BackgroundImagePicker.tsx
  • packages/koenig-lexical/src/components/ui/Button.stories.tsx
  • packages/koenig-lexical/src/components/ui/Button.tsx
  • packages/koenig-lexical/src/components/ui/ButtonGroup.stories.tsx
  • packages/koenig-lexical/src/components/ui/ButtonGroup.tsx
  • packages/koenig-lexical/src/components/ui/CardCaptionEditor.tsx
  • packages/koenig-lexical/src/components/ui/CardMenu.stories.tsx
  • packages/koenig-lexical/src/components/ui/CardMenu.tsx
  • packages/koenig-lexical/src/components/ui/CardVisibilityMessage.tsx
  • packages/koenig-lexical/src/components/ui/CardWrapper.tsx
  • packages/koenig-lexical/src/components/ui/ColorOptionButtons.tsx
  • packages/koenig-lexical/src/components/ui/ColorPicker.stories.tsx
  • packages/koenig-lexical/src/components/ui/ColorPicker.tsx
  • packages/koenig-lexical/src/components/ui/Delayed.tsx
  • packages/koenig-lexical/src/components/ui/Dropdown.stories.tsx
  • packages/koenig-lexical/src/components/ui/Dropdown.tsx
  • packages/koenig-lexical/src/components/ui/DropdownContainer.tsx
  • packages/koenig-lexical/src/components/ui/EditorPlaceholder.tsx
  • packages/koenig-lexical/src/components/ui/EmojiPicker.tsx
  • packages/koenig-lexical/src/components/ui/EmojiPickerPortal.tsx
  • packages/koenig-lexical/src/components/ui/FileUploadForm.jsx
  • packages/koenig-lexical/src/components/ui/FileUploadForm.tsx
  • packages/koenig-lexical/src/components/ui/FloatingFormatToolbar.tsx
  • packages/koenig-lexical/src/components/ui/FloatingLinkToolbar.tsx
  • packages/koenig-lexical/src/components/ui/FloatingToolbar.tsx
  • packages/koenig-lexical/src/components/ui/FormatToolbar.tsx
  • packages/koenig-lexical/src/components/ui/HighlightedString.tsx
  • packages/koenig-lexical/src/components/ui/IconButton.stories.tsx
  • packages/koenig-lexical/src/components/ui/IconButton.tsx
  • packages/koenig-lexical/src/components/ui/ImageUploadForm.tsx
  • packages/koenig-lexical/src/components/ui/ImageUploadSwatch.tsx
  • packages/koenig-lexical/src/components/ui/Input.stories.tsx
  • packages/koenig-lexical/src/components/ui/Input.tsx
  • packages/koenig-lexical/src/components/ui/InputList.stories.tsx
  • packages/koenig-lexical/src/components/ui/InputList.tsx
  • packages/koenig-lexical/src/components/ui/KeyboardSelection.tsx
  • packages/koenig-lexical/src/components/ui/KeyboardSelectionWithGroups.tsx
  • packages/koenig-lexical/src/components/ui/LinkActionToolbar.tsx
  • packages/koenig-lexical/src/components/ui/LinkActionToolbarWithSearch.tsx
  • packages/koenig-lexical/src/components/ui/LinkInput.stories.tsx
  • packages/koenig-lexical/src/components/ui/LinkInput.tsx
  • packages/koenig-lexical/src/components/ui/LinkInputSearchItem.tsx
  • packages/koenig-lexical/src/components/ui/LinkInputWithSearch.stories.tsx
  • packages/koenig-lexical/src/components/ui/LinkInputWithSearch.tsx
  • packages/koenig-lexical/src/components/ui/LinkToolbar.stories.tsx
  • packages/koenig-lexical/src/components/ui/LinkToolbar.tsx
  • packages/koenig-lexical/src/components/ui/MediaPlaceholder.stories.tsx
  • packages/koenig-lexical/src/components/ui/MediaPlaceholder.tsx
  • packages/koenig-lexical/src/components/ui/MediaPlayer.stories.tsx
  • packages/koenig-lexical/src/components/ui/MediaPlayer.tsx
  • packages/koenig-lexical/src/components/ui/MediaUploader.tsx
  • packages/koenig-lexical/src/components/ui/Modal.stories.tsx
  • packages/koenig-lexical/src/components/ui/Modal.tsx
  • packages/koenig-lexical/src/components/ui/MultiSelectDropdown.tsx
  • packages/koenig-lexical/src/components/ui/PlusMenu.stories.tsx
  • packages/koenig-lexical/src/components/ui/PlusMenu.tsx
  • packages/koenig-lexical/src/components/ui/Portal.tsx
  • packages/koenig-lexical/src/components/ui/ProgressBar.stories.tsx
  • packages/koenig-lexical/src/components/ui/ProgressBar.tsx
  • packages/koenig-lexical/src/components/ui/ReadOnlyOverlay.tsx
  • packages/koenig-lexical/src/components/ui/SettingsPanel.stories.tsx
  • packages/koenig-lexical/src/components/ui/SettingsPanel.tsx
  • packages/koenig-lexical/src/components/ui/SlashMenu.jsx
  • packages/koenig-lexical/src/components/ui/SlashMenu.tsx
  • packages/koenig-lexical/src/components/ui/Slider.stories.tsx
  • packages/koenig-lexical/src/components/ui/Slider.tsx
  • packages/koenig-lexical/src/components/ui/SnippetActionToolbar.tsx
  • packages/koenig-lexical/src/components/ui/SnippetInput.stories.tsx
  • packages/koenig-lexical/src/components/ui/SnippetInput.tsx
  • packages/koenig-lexical/src/components/ui/SnippetInput/Dropdown.tsx
  • packages/koenig-lexical/src/components/ui/SnippetInput/Input.tsx
  • packages/koenig-lexical/src/components/ui/Spinner.tsx
  • packages/koenig-lexical/src/components/ui/SubscribeForm.stories.tsx
  • packages/koenig-lexical/src/components/ui/SubscribeForm.tsx
  • packages/koenig-lexical/src/components/ui/TabView.tsx
  • packages/koenig-lexical/src/components/ui/TenorPlugin.tsx
  • packages/koenig-lexical/src/components/ui/TenorSelector.stories.tsx
  • packages/koenig-lexical/src/components/ui/TenorSelector.tsx
  • packages/koenig-lexical/src/components/ui/TextInput.tsx
  • packages/koenig-lexical/src/components/ui/Toggle.stories.tsx
  • packages/koenig-lexical/src/components/ui/Toggle.tsx
  • packages/koenig-lexical/src/components/ui/ToolbarMenu.stories.tsx
  • packages/koenig-lexical/src/components/ui/ToolbarMenu.tsx
  • packages/koenig-lexical/src/components/ui/ToolbarMenuItem.stories.tsx
  • packages/koenig-lexical/src/components/ui/Tooltip.tsx
  • packages/koenig-lexical/src/components/ui/UnsplashPlugin.tsx
  • packages/koenig-lexical/src/components/ui/UrlInput.stories.tsx
  • packages/koenig-lexical/src/components/ui/UrlInput.tsx
  • packages/koenig-lexical/src/components/ui/UrlSearchInput.stories.tsx
  • packages/koenig-lexical/src/components/ui/UrlSearchInput.tsx
  • packages/koenig-lexical/src/components/ui/VisibilitySettings.tsx
  • packages/koenig-lexical/src/components/ui/cards/AudioCard.stories.tsx
  • packages/koenig-lexical/src/components/ui/cards/AudioCard.tsx
  • packages/koenig-lexical/src/components/ui/cards/BookmarkCard.stories.tsx
  • packages/koenig-lexical/src/components/ui/cards/BookmarkCard.tsx
  • packages/koenig-lexical/src/components/ui/cards/ButtonCard.stories.tsx
  • packages/koenig-lexical/src/components/ui/cards/ButtonCard.tsx
  • packages/koenig-lexical/src/components/ui/cards/CallToActionCard.stories.tsx
  • packages/koenig-lexical/src/components/ui/cards/CallToActionCard.tsx
  • packages/koenig-lexical/src/components/ui/cards/CalloutCard.stories.tsx
  • packages/koenig-lexical/src/components/ui/cards/CalloutCard.tsx
  • packages/koenig-lexical/src/components/ui/cards/CodeBlockCard.jsx
  • packages/koenig-lexical/src/components/ui/cards/CodeBlockCard.stories.tsx
  • packages/koenig-lexical/src/components/ui/cards/CodeBlockCard.tsx
  • packages/koenig-lexical/src/components/ui/cards/EmailCard.stories.tsx
  • packages/koenig-lexical/src/components/ui/cards/EmailCard.tsx
  • packages/koenig-lexical/src/components/ui/cards/EmailCtaCard.stories.tsx
  • packages/koenig-lexical/src/components/ui/cards/EmailCtaCard.tsx
  • packages/koenig-lexical/src/components/ui/cards/EmbedCard.stories.tsx
  • packages/koenig-lexical/src/components/ui/cards/EmbedCard.tsx
  • packages/koenig-lexical/src/components/ui/cards/FileCard.stories.tsx
  • packages/koenig-lexical/src/components/ui/cards/FileCard.tsx
  • packages/koenig-lexical/src/components/ui/cards/GalleryCard.stories.tsx
  • packages/koenig-lexical/src/components/ui/cards/GalleryCard.tsx
  • packages/koenig-lexical/src/components/ui/cards/HeaderCard/v1/HeaderCard.tsx
  • packages/koenig-lexical/src/components/ui/cards/HeaderCard/v1/HeaderCard.v1.stories.tsx
  • packages/koenig-lexical/src/components/ui/cards/HeaderCard/v2/HeaderCard.tsx
  • packages/koenig-lexical/src/components/ui/cards/HeaderCard/v2/HeaderCard.v2.stories.tsx
  • packages/koenig-lexical/src/components/ui/cards/HorizontalRuleCard.stories.tsx
  • packages/koenig-lexical/src/components/ui/cards/HorizontalRuleCard.tsx
  • packages/koenig-lexical/src/components/ui/cards/HtmlCard.stories.tsx
  • packages/koenig-lexical/src/components/ui/cards/HtmlCard.tsx
  • packages/koenig-lexical/src/components/ui/cards/HtmlCard/HtmlEditor.tsx
  • packages/koenig-lexical/src/components/ui/cards/ImageCard.stories.tsx
  • packages/koenig-lexical/src/components/ui/cards/ImageCard.tsx
  • packages/koenig-lexical/src/components/ui/cards/MarkdownCard.stories.tsx
  • packages/koenig-lexical/src/components/ui/cards/MarkdownCard.tsx
  • packages/koenig-lexical/src/components/ui/cards/MarkdownCard/MarkdownEditor.tsx
  • packages/koenig-lexical/src/components/ui/cards/MarkdownCard/MarkdownHelpDialog.tsx
  • packages/koenig-lexical/src/components/ui/cards/MarkdownCard/MarkdownImageUploader.tsx
  • packages/koenig-lexical/src/components/ui/cards/MarkdownCard/useMarkdownImageUploader.ts
  • packages/koenig-lexical/src/components/ui/cards/PaywallCard.stories.tsx
  • packages/koenig-lexical/src/components/ui/cards/PaywallCard.tsx
  • packages/koenig-lexical/src/components/ui/cards/ProductCard.stories.tsx
  • packages/koenig-lexical/src/components/ui/cards/ProductCard.tsx
  • packages/koenig-lexical/src/components/ui/cards/ProductCard/ProductCardImage.tsx
  • packages/koenig-lexical/src/components/ui/cards/ProductCard/RatingButton.tsx
  • packages/koenig-lexical/src/components/ui/cards/SignupCard.stories.tsx
  • packages/koenig-lexical/src/components/ui/cards/SignupCard.tsx
  • packages/koenig-lexical/src/components/ui/cards/ToggleCard.stories.tsx
  • packages/koenig-lexical/src/components/ui/cards/ToggleCard.tsx
  • packages/koenig-lexical/src/components/ui/cards/TransistorCard.tsx
  • packages/koenig-lexical/src/components/ui/cards/VideoCard.stories.tsx
  • packages/koenig-lexical/src/components/ui/cards/VideoCard.tsx
  • packages/koenig-lexical/src/components/ui/file-selectors/Tenor/Error.tsx
  • packages/koenig-lexical/src/components/ui/file-selectors/Tenor/Gif.tsx
  • packages/koenig-lexical/src/components/ui/file-selectors/Tenor/Loader.tsx
  • packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashButton.tsx
  • packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashGallery.tsx
  • packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashImage.tsx
  • packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashSelector.stories.tsx
  • packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashSelector.tsx
  • packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashZoomed.tsx
  • packages/koenig-lexical/src/components/ui/file-selectors/UnsplashModal.jsx
  • packages/koenig-lexical/src/components/ui/file-selectors/UnsplashModal.tsx
  • packages/koenig-lexical/src/context/CardContext.jsx
  • packages/koenig-lexical/src/context/CardContext.tsx
  • packages/koenig-lexical/src/context/KoenigComposerContext.jsx
  • packages/koenig-lexical/src/context/KoenigComposerContext.tsx
  • packages/koenig-lexical/src/context/KoenigSelectedCardContext.tsx
  • packages/koenig-lexical/src/context/SharedHistoryContext.tsx
  • packages/koenig-lexical/src/context/SharedOnChangeContext.jsx
  • packages/koenig-lexical/src/context/SharedOnChangeContext.tsx
  • packages/koenig-lexical/src/context/TKContext.tsx
  • packages/koenig-lexical/src/hooks/useCardDragAndDrop.ts
  • packages/koenig-lexical/src/hooks/useClickOutside.ts
  • packages/koenig-lexical/src/hooks/useFileDragAndDrop.ts
  • packages/koenig-lexical/src/hooks/useGalleryReorder.ts
  • packages/koenig-lexical/src/hooks/useInputSelection.ts
  • packages/koenig-lexical/src/hooks/useKoenigTextEntity.ts
  • packages/koenig-lexical/src/hooks/useMovable.ts
  • packages/koenig-lexical/src/hooks/usePinturaEditor.ts
  • packages/koenig-lexical/src/hooks/usePreviousFocus.js
  • packages/koenig-lexical/src/hooks/usePreviousFocus.ts
  • packages/koenig-lexical/src/hooks/useSearchLinks.ts
  • packages/koenig-lexical/src/hooks/useSettingsPanelReposition.ts
  • packages/koenig-lexical/src/hooks/useTypeaheadTriggerMatch.ts
  • packages/koenig-lexical/src/hooks/useVisibilityToggle.ts
  • packages/koenig-lexical/src/index.ts
  • packages/koenig-lexical/src/nodes/AsideNode.ts
  • packages/koenig-lexical/src/nodes/AudioNode.tsx
  • packages/koenig-lexical/src/nodes/AudioNodeComponent.tsx
  • packages/koenig-lexical/src/nodes/BasicNodes.ts
  • packages/koenig-lexical/src/nodes/BookmarkNode.tsx
  • packages/koenig-lexical/src/nodes/BookmarkNodeComponent.tsx
  • packages/koenig-lexical/src/nodes/ButtonNode.tsx
  • packages/koenig-lexical/src/nodes/ButtonNodeComponent.tsx
  • packages/koenig-lexical/src/nodes/CallToActionNode.tsx
  • packages/koenig-lexical/src/nodes/CallToActionNodeComponent.tsx
  • packages/koenig-lexical/src/nodes/CalloutNode.tsx
  • packages/koenig-lexical/src/nodes/CalloutNodeComponent.tsx
  • packages/koenig-lexical/src/nodes/CodeBlockNode.tsx
  • packages/koenig-lexical/src/nodes/CodeBlockNodeComponent.tsx
  • packages/koenig-lexical/src/nodes/DefaultNodes.ts
  • packages/koenig-lexical/src/nodes/EmailCtaNode.tsx
  • packages/koenig-lexical/src/nodes/EmailCtaNodeComponent.tsx
  • packages/koenig-lexical/src/nodes/EmailEditorNodes.ts
  • packages/koenig-lexical/src/nodes/EmailEmbedNode.tsx
  • packages/koenig-lexical/src/nodes/EmailNode.tsx
  • packages/koenig-lexical/src/nodes/EmailNodeComponent.tsx
  • packages/koenig-lexical/src/nodes/EmailNodes.ts
  • packages/koenig-lexical/src/nodes/EmbedNode.tsx
  • packages/koenig-lexical/src/nodes/EmbedNodeComponent.tsx
  • packages/koenig-lexical/src/nodes/FileNode.tsx
  • packages/koenig-lexical/src/nodes/FileNodeComponent.tsx
  • packages/koenig-lexical/src/nodes/GalleryNode.tsx
  • packages/koenig-lexical/src/nodes/GalleryNodeComponent.tsx
  • packages/koenig-lexical/src/nodes/HeaderNode.tsx
  • packages/koenig-lexical/src/nodes/HorizontalRuleNode.tsx
  • packages/koenig-lexical/src/nodes/HtmlNode.tsx
  • packages/koenig-lexical/src/nodes/HtmlNodeComponent.tsx
  • packages/koenig-lexical/src/nodes/ImageNode.tsx
  • packages/koenig-lexical/src/nodes/ImageNodeComponent.tsx
  • packages/koenig-lexical/src/nodes/MarkdownNode.tsx
  • packages/koenig-lexical/src/nodes/MarkdownNodeComponent.tsx
  • packages/koenig-lexical/src/nodes/MinimalNodes.ts
  • packages/koenig-lexical/src/nodes/PaywallNode.tsx
  • packages/koenig-lexical/src/nodes/ProductNode.tsx
  • packages/koenig-lexical/src/nodes/ProductNodeComponent.tsx
  • packages/koenig-lexical/src/nodes/SignupNode.tsx
  • packages/koenig-lexical/src/nodes/SignupNodeComponent.tsx
  • packages/koenig-lexical/src/nodes/ToggleNode.tsx
  • packages/koenig-lexical/src/nodes/ToggleNodeComponent.tsx
  • packages/koenig-lexical/src/nodes/TransistorNode.tsx
  • packages/koenig-lexical/src/nodes/TransistorNodeComponent.tsx
  • packages/koenig-lexical/src/nodes/VideoNode.tsx
  • packages/koenig-lexical/src/nodes/VideoNodeComponent.tsx
  • packages/koenig-lexical/src/nodes/header/v1/HeaderNodeComponent.tsx
  • packages/koenig-lexical/src/nodes/header/v2/HeaderNodeComponent.tsx
  • packages/koenig-lexical/src/plugins/AllDefaultPlugins.tsx
  • packages/koenig-lexical/src/plugins/AtLinkPlugin.tsx
  • packages/koenig-lexical/src/plugins/AudioPlugin.tsx
  • packages/koenig-lexical/src/plugins/BookmarkPlugin.tsx
  • packages/koenig-lexical/src/plugins/ButtonPlugin.tsx
  • packages/koenig-lexical/src/plugins/CallToActionPlugin.tsx
  • packages/koenig-lexical/src/plugins/CalloutPlugin.tsx
  • packages/koenig-lexical/src/plugins/CardMenuPlugin.tsx
  • packages/koenig-lexical/src/plugins/DragDropPastePlugin.tsx
  • packages/koenig-lexical/src/plugins/DragDropReorderPlugin.tsx
  • packages/koenig-lexical/src/plugins/EmEnDashPlugin.tsx
  • packages/koenig-lexical/src/plugins/EmailCtaPlugin.tsx
  • packages/koenig-lexical/src/plugins/EmailPlugin.tsx
  • packages/koenig-lexical/src/plugins/EmbedPlugin.tsx
  • packages/koenig-lexical/src/plugins/EmojiPickerPlugin.tsx
  • packages/koenig-lexical/src/plugins/ExternalControlPlugin.tsx
  • packages/koenig-lexical/src/plugins/FilePlugin.tsx
  • packages/koenig-lexical/src/plugins/FloatingToolbarPlugin.tsx
  • packages/koenig-lexical/src/plugins/GalleryPlugin.tsx
  • packages/koenig-lexical/src/plugins/HeaderPlugin.tsx
  • packages/koenig-lexical/src/plugins/HorizontalRulePlugin.tsx
  • packages/koenig-lexical/src/plugins/HtmlOutputPlugin.tsx
  • packages/koenig-lexical/src/plugins/HtmlPlugin.tsx
  • packages/koenig-lexical/src/plugins/ImagePlugin.tsx
  • packages/koenig-lexical/src/plugins/KoenigBehaviourPlugin.tsx
  • packages/koenig-lexical/src/plugins/KoenigBlurPlugin.tsx
  • packages/koenig-lexical/src/plugins/KoenigFocusPlugin.tsx
  • packages/koenig-lexical/src/plugins/KoenigNestedEditorPlugin.tsx
  • packages/koenig-lexical/src/plugins/KoenigSelectorPlugin.tsx
  • packages/koenig-lexical/src/plugins/KoenigSnippetPlugin.tsx
  • packages/koenig-lexical/src/plugins/MarkdownPastePlugin.tsx
  • packages/koenig-lexical/src/plugins/MarkdownPlugin.tsx
  • packages/koenig-lexical/src/plugins/MarkdownShortcutPlugin.tsx
  • packages/koenig-lexical/src/plugins/PaywallPlugin.tsx
💤 Files with no reviewable changes (9)
  • packages/koenig-lexical/src/components/ui/SlashMenu.jsx
  • packages/koenig-lexical/src/components/ui/file-selectors/UnsplashModal.jsx
  • packages/koenig-lexical/src/context/CardContext.jsx
  • packages/koenig-lexical/src/context/KoenigComposerContext.jsx
  • packages/koenig-lexical/src/components/ui/AudioUploadForm.jsx
  • packages/koenig-lexical/src/components/ui/FileUploadForm.jsx
  • packages/koenig-lexical/src/components/ui/cards/CodeBlockCard.jsx
  • packages/koenig-lexical/src/context/SharedOnChangeContext.jsx
  • packages/koenig-lexical/src/hooks/usePreviousFocus.js

Comment on lines +4 to +10
interface SidebarProps {
isOpen: boolean;
view: string;
saveContent?: () => void;
}

const Sidebar = ({isOpen, view, saveContent}: SidebarProps) => {
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify Sidebar call sites pass valid literal views and correct saveContent pairing.
rg -n -C2 '<Sidebar\b|view=\{?["'\'']?(json|tree)|saveContent=' --type=tsx --type=jsx

Repository: TryGhost/Koenig

Length of output: 86


🏁 Script executed:

# Search for Sidebar component usage with corrected flags
rg -n '<Sidebar' --glob='*.{tsx,jsx}' -C2

Repository: TryGhost/Koenig

Length of output: 1765


🏁 Script executed:

# Also read the full Sidebar.tsx file to understand the implementation
cat packages/koenig-lexical/demo/components/Sidebar.tsx

Repository: TryGhost/Koenig

Length of output: 941


🏁 Script executed:

# Check RestrictedContentDemo for sidebarView initialization/values
rg -n 'sidebarView' packages/koenig-lexical/demo/RestrictedContentDemo.tsx -B3 -A3

Repository: TryGhost/Koenig

Length of output: 1086


🏁 Script executed:

# Check HtmlOutputDemo for sidebarView initialization/values
rg -n 'sidebarView' packages/koenig-lexical/demo/HtmlOutputDemo.tsx -B3 -A3

Repository: TryGhost/Koenig

Length of output: 1245


🏁 Script executed:

# Check DemoApp for sidebarView initialization/values
rg -n 'sidebarView' packages/koenig-lexical/demo/DemoApp.tsx -B3 -A3

Repository: TryGhost/Koenig

Length of output: 1164


🏁 Script executed:

# Check if openSidebar is ever called with a different view in RestrictedContentDemo
rg -n 'openSidebar' packages/koenig-lexical/demo/RestrictedContentDemo.tsx

Repository: TryGhost/Koenig

Length of output: 191


🏁 Script executed:

# Check if openSidebar is ever called with a different view in HtmlOutputDemo
rg -n 'openSidebar' packages/koenig-lexical/demo/HtmlOutputDemo.tsx

Repository: TryGhost/Koenig

Length of output: 195


🏁 Script executed:

# Check what FloatingButton does - does it ever call openSidebar with a 'tree' argument
rg -n 'FloatingButton' packages/koenig-lexical/demo/components/ -A20

Repository: TryGhost/Koenig

Length of output: 2052


🏁 Script executed:

# Also verify the actual FloatingButton component implementation
find packages/koenig-lexical/demo -name 'FloatingButton*'

Repository: TryGhost/Koenig

Length of output: 117


Fix runtime bug in RestrictedContentDemo and HtmlOutputDemo by refactoring SidebarProps with a discriminated union.

Currently, RestrictedContentDemo.tsx (line 117) and HtmlOutputDemo.tsx (line 111) pass Sidebar without saveContent, but when users click the "JSON output" button, the component attempts to render <button onClick={saveContent}> with an undefined callback, causing a runtime error. Refactor SidebarProps as a discriminated union to enforce that saveContent is required when view is 'json' and forbidden when it is 'tree'.

♻️ Proposed typing refactor
-interface SidebarProps {
-    isOpen: boolean;
-    view: string;
-    saveContent?: () => void;
-}
+type SidebarProps =
+    | {isOpen: boolean; view: 'json'; saveContent: () => void}
+    | {isOpen: boolean; view: 'tree'; saveContent?: never};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/demo/components/Sidebar.tsx` around lines 4 - 10,
SidebarProps currently allows absence of saveContent causing a runtime error
when Sidebar (component Sidebar) is rendered with view === 'json' but
saveContent is undefined; refactor SidebarProps into a discriminated union: one
variant { view: 'json'; isOpen: boolean; saveContent: () => void } and another
variant { view: 'tree'; isOpen: boolean } so the type system enforces
saveContent is required for 'json' and forbidden for 'tree'; update the Sidebar
component signature to use this union and narrow by view before calling
saveContent, and update callers (RestrictedContentDemo and HtmlOutputDemo) to
pass saveContent when they render Sidebar with view='json' or switch their view
to 'tree' to avoid passing saveContent.

Comment on lines +2 to 4
import React, {useState} from 'react';
import {Modal} from './Modal';
import {useState} from 'react';
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

cat -n packages/koenig-lexical/src/components/ui/Modal.stories.tsx | head -30

Repository: TryGhost/Koenig

Length of output: 1004


Remove duplicate useState import to avoid module-scope redeclaration.

useState is imported on Line 2 and again on Line 4, which causes a duplicate identifier error in TS/ESM parsing.

Proposed fix
 import React, {useState} from 'react';
 import {Modal} from './Modal';
-import {useState} from 'react';
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import React, {useState} from 'react';
import {Modal} from './Modal';
import {useState} from 'react';
import React, {useState} from 'react';
import {Modal} from './Modal';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/ui/Modal.stories.tsx` around lines 2 -
4, The file imports useState twice causing a module-scope redeclaration; remove
the duplicate import statement (the second "import {useState} from 'react';")
and ensure useState is only imported once from React (e.g., keep "import React,
{useState} from 'react';") so references in Modal.stories.tsx and the Modal
component remain valid.

Comment on lines 132 to 139
function highlightPrev() {
if (highlightedGif.index === 0) {
if (highlightedGif!.index === 0) {
// reached the beginning, focus the search bar
focusSearch();
}

setHighlightedGif(gifs[highlightedGif.index - 1]);
setHighlightedGif(gifs[highlightedGif!.index - 1]);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

highlightPrev accesses gifs[-1] when index is 0.

When highlightedGif.index === 0, the function calls focusSearch() but does not return early. Execution continues to line 138 which accesses gifs[0 - 1] = gifs[-1], resulting in undefined being set as the highlighted GIF.

Add early return after focusSearch()
     function highlightPrev() {
         if (highlightedGif!.index === 0) {
             // reached the beginning, focus the search bar
             focusSearch();
+            return;
         }

         setHighlightedGif(gifs[highlightedGif!.index - 1]);
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function highlightPrev() {
if (highlightedGif.index === 0) {
if (highlightedGif!.index === 0) {
// reached the beginning, focus the search bar
focusSearch();
}
setHighlightedGif(gifs[highlightedGif.index - 1]);
setHighlightedGif(gifs[highlightedGif!.index - 1]);
}
function highlightPrev() {
if (highlightedGif!.index === 0) {
// reached the beginning, focus the search bar
focusSearch();
return;
}
setHighlightedGif(gifs[highlightedGif!.index - 1]);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/ui/TenorSelector.tsx` around lines 132
- 139, The bug is that highlightPrev() calls focusSearch() when
highlightedGif!.index === 0 but then continues and does
setHighlightedGif(gifs[highlightedGif!.index - 1]) which reads gifs[-1]; fix
highlightPrev() so that after calling focusSearch() it returns early to avoid
accessing a negative index, i.e. inside highlightPrev() check highlightedGif (or
keep the existing non-null assertion), call focusSearch() when
highlightedGif.index === 0 and immediately return; otherwise call
setHighlightedGif(gifs[highlightedGif.index - 1]).

Comment on lines 7 to 15
useEffect(() => {
editor.registerCommand(
BLUR_COMMAND,
() => {
onBlur?.();
return false;
},
COMMAND_PRIORITY_EDITOR
);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Missing cleanup for command registration causes memory leak.

editor.registerCommand returns an unregister function that must be returned from the useEffect to properly clean up when the component unmounts or dependencies change. Without this, the command handler remains registered.

🐛 Proposed fix
     useEffect(() => {
-        editor.registerCommand(
+        return editor.registerCommand(
             BLUR_COMMAND,
             () => {
                 onBlur?.();
                 return false;
             },
             COMMAND_PRIORITY_EDITOR
         );
     }, [editor, onBlur]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
editor.registerCommand(
BLUR_COMMAND,
() => {
onBlur?.();
return false;
},
COMMAND_PRIORITY_EDITOR
);
useEffect(() => {
return editor.registerCommand(
BLUR_COMMAND,
() => {
onBlur?.();
return false;
},
COMMAND_PRIORITY_EDITOR
);
}, [editor, onBlur]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/plugins/KoenigBlurPlugin.tsx` around lines 7 -
15, The useEffect registers BLUR_COMMAND via editor.registerCommand (with
COMMAND_PRIORITY_EDITOR) but never cleans up, causing a leak; update the
useEffect that calls editor.registerCommand to capture the unregister function
it returns and return a cleanup function that calls it (ensuring onBlur and the
registration are removed when the component unmounts or dependencies change),
referencing the same BLUR_COMMAND/COMMAND_PRIORITY_EDITOR registration so the
exact handler is unregistered.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 9

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (7)
packages/koenig-lexical/src/hooks/useCardDragAndDrop.ts (1)

85-120: ⚠️ Potential issue | 🟠 Major

Destroy the registered drag container on effect cleanup.

This effect re-registers a container whenever its dependencies change, but it never calls destroy(). That leaves stale listeners behind and can fire duplicate drag/drop callbacks after rerenders.

💡 Suggested fix
     React.useEffect(() => {
         if (!containerRef || !koenig.dragDropHandler) {
             return;
         }
 
-        dragDropContainer.current = (koenig.dragDropHandler as DragDropHandler).registerContainer(
+        const container = (koenig.dragDropHandler as DragDropHandler).registerContainer(
             containerRef,
             {
                 draggableSelector,
                 droppableSelector,
                 isDragEnabled: enabled,
@@
                 onDrop: _onDrop,
                 onDropEnd: _onDropEnd
             }
         );
+
+        dragDropContainer.current = container;
+
+        return () => {
+            container.destroy();
+            if (dragDropContainer.current === container) {
+                dragDropContainer.current = null;
+            }
+        };
     }, [
         _getDraggableInfo,
         _getIndicatorPosition,
         _onDrop,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/hooks/useCardDragAndDrop.ts` around lines 85 -
120, The effect registers a drag container via (koenig.dragDropHandler as
DragDropHandler).registerContainer and never destroys it; add a cleanup function
to the React.useEffect that, when run, calls destroy() on
dragDropContainer.current (if present) and clears dragDropContainer.current to
null to avoid leaking listeners and duplicate callbacks; keep the existing
dependencies (_getDraggableInfo, _getIndicatorPosition, _onDrop, _onDropEnd,
containerRef, draggableSelector, droppableSelector, enabled,
koenig.dragDropHandler, onDragEnd, onDragEnterContainer, onDragLeaveContainer,
onDragStart) and ensure the cleanup runs before re-registering.
packages/koenig-lexical/src/plugins/EmojiPickerPlugin.tsx (1)

170-176: ⚠️ Potential issue | 🟡 Minor

Guard the DOM selection before calling getRangeAt(0).

window.getSelection() can be null, and rangeCount can be 0 after blur or while the menu is settling. The non-null assertion turns that into a render-time crash.

💡 Suggested fix
 function getPositionStyles() {
-    const selectedRange = window.getSelection()!.getRangeAt(0);
+    const selection = window.getSelection();
+    if (!selection || selection.rangeCount === 0) {
+        return undefined;
+    }
+    const selectedRange = selection.getRangeAt(0);
     const rangeRect = selectedRange.getBoundingClientRect();
 
     return {
         marginTop: `${rangeRect.height}px`
     };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/plugins/EmojiPickerPlugin.tsx` around lines 170 -
176, The getPositionStyles function currently calls window.getSelection()! and
getRangeAt(0) unguarded which can throw if selection is null or rangeCount is 0;
update getPositionStyles to first get const sel = window.getSelection(), check
sel is non-null and sel.rangeCount > 0 before calling sel.getRangeAt(0) and
using its bounding rect, and return a safe fallback style (e.g., { marginTop:
'0px' } or undefined) when there is no valid range so rendering cannot crash.
packages/koenig-lexical/src/plugins/KoenigFocusPlugin.tsx (1)

7-16: ⚠️ Potential issue | 🟠 Major

Missing cleanup return from useEffect causes listener leak.

editor.registerCommand returns an unregister function that must be returned from the useEffect to clean up when the component unmounts or dependencies change. Currently, the cleanup function is discarded.

Proposed fix
     useEffect(() => {
-        editor.registerCommand(
+        return editor.registerCommand(
             FOCUS_COMMAND,
             () => {
                 onFocus?.();
                 return false;
             },
             COMMAND_PRIORITY_EDITOR
         );
     }, [editor, onFocus]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/plugins/KoenigFocusPlugin.tsx` around lines 7 -
16, The effect registering the focus handler is leaking because
editor.registerCommand (called inside useEffect) returns an unregister function
that isn't used; update the useEffect that calls editor.registerCommand with
FOCUS_COMMAND and COMMAND_PRIORITY_EDITOR to capture the returned cleanup
function and return it from the effect so the listener is removed when
dependencies (editor, onFocus) change or the component unmounts; keep the
existing call to onFocus() in the registered command.
packages/koenig-lexical/src/components/ui/UrlSearchInput.tsx (1)

53-59: ⚠️ Potential issue | 🟠 Major

Move event handlers from nested <span> to <button> elements for proper keyboard and click interaction.

Lines 56–57 attach click handlers to inner <span> elements instead of their parent <button> elements. This breaks keyboard activation (Enter/Space keys) and makes clicks on button padding ineffective—only clicks on the text itself will trigger the handlers.

Suggested fix
-<button className="mr-3 cursor-pointer" data-testid={`${dataTestId}-error-retry`} type="button"><span className="font-semibold underline" onClick={handleRetry}>Retry</span></button>
-<button className="mr-3 cursor-pointer" data-testid={`${dataTestId}-error-pasteAsLink`} type="button"><span className="font-semibold underline" onClick={() => handlePasteAsLink?.(value)}>Paste URL as link</span></button>
+<button className="mr-3 cursor-pointer" data-testid={`${dataTestId}-error-retry`} type="button" onClick={handleRetry}><span className="font-semibold underline">Retry</span></button>
+<button className="mr-3 cursor-pointer" data-testid={`${dataTestId}-error-pasteAsLink`} type="button" onClick={() => handlePasteAsLink?.(value)}><span className="font-semibold underline">Paste URL as link</span></button>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/ui/UrlSearchInput.tsx` around lines 53
- 59, The click handlers for the retry and paste-as-link actions are attached to
inner <span> elements which breaks keyboard activation and click area; move the
onClick handlers from the inner spans to their parent <button> elements (the
buttons with data-testid values `${dataTestId}-error-retry` and
`${dataTestId}-error-pasteAsLink`) and remove the onClick from the spans so
handleRetry and handlePasteAsLink(value) are invoked by the button elements
instead; ensure the buttons keep type="button" and accessible labeling/styling
(the spans may remain for styling only).
packages/koenig-lexical/src/components/ui/KeyboardSelectionWithGroups.tsx (1)

41-63: ⚠️ Potential issue | 🟠 Major

Short-circuit keyboard handling when there are no items.

When items.length === 0, ArrowDown drives selectedIndex to -1, and Enter passes undefined to onSelect. Unlike mouse interactions (which check if (item.value) before updating selection), the keyboard handler lacks guards and can leave the component in an invalid state on no-results screens.

Suggested fix
 const handleKeydown = React.useCallback((event: KeyboardEvent) => {
+    if (items.length === 0) {
+        return;
+    }
+
     if (event.key === 'ArrowDown') {
         event.preventDefault();
         event.stopPropagation();
         setSelectedIndex((i) => {
             return Math.min(i + 1, items.length - 1);
         });
         setScrollSelectedIntoView(true);
     }
     if (event.key === 'ArrowUp') {
         event.preventDefault();
         event.stopPropagation();
         setSelectedIndex((i) => {
             return Math.max(i - 1, 0);
         });
         setScrollSelectedIntoView(true);
     }
     if (event.key === 'Enter') {
         event.preventDefault();
         event.stopPropagation();
-        onSelect(items[selectedIndex]);
+        const selectedItem = items[selectedIndex];
+        if (selectedItem) {
+            onSelect(selectedItem);
+        }
     }
 }, [items, selectedIndex, onSelect]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/ui/KeyboardSelectionWithGroups.tsx`
around lines 41 - 63, The keyboard handler handleKeydown should short-circuit
when there are no items: at the start of handleKeydown check items.length === 0
and return early so ArrowDown/ArrowUp don't drive selectedIndex to -1 and Enter
doesn't call onSelect with undefined; additionally, when handling Enter ensure
the selected item exists (e.g., guard that items[selectedIndex] is truthy)
before calling onSelect, and keep the existing use of setSelectedIndex and
setScrollSelectedIntoView for valid navigation only.
packages/koenig-lexical/src/components/ui/cards/HeaderCard/v1/HeaderCard.tsx (1)

232-247: ⚠️ Potential issue | 🟡 Minor

Make the button-editing handlers required when button is enabled.

This branch renders editable controls whenever button is truthy (line 233), but handleButtonText and handleButtonUrl are both optional in the props interface. The code passes handleButtonUrl! with a non-null assertion (line 247) while handleButtonText is passed without assertion (line 240), masking a type contract violation. Callers can pass button={true} without providing these handlers, leading to runtime errors on the first change event. A discriminated union on button would enforce this invariant at the type level.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/ui/cards/HeaderCard/v1/HeaderCard.tsx`
around lines 232 - 247, The component renders button-editing controls when the
prop button is truthy but props handleButtonText and handleButtonUrl are
optional and currently asserted with handleButtonUrl!, causing possible runtime
errors; change the HeaderCard props to a discriminated union on button (e.g.
{button: true; handleButtonText: ..., handleButtonUrl: ...} | {button?: false;
handleButtonText?: ..., handleButtonUrl?: ...}) so that when button is true the
handlers are required, update usages/callers to satisfy the new type, and remove
the non-null assertion on handleButtonUrl when passing handlers into
InputSetting and InputUrlSetting.
packages/koenig-lexical/src/components/ui/SettingsPanel.tsx (1)

34-39: ⚠️ Potential issue | 🟠 Major

Guard tabContent shape before passing to TabView.

Line 38 currently treats any object-like children as tab-content map. React elements/arrays are also objects, so with tabs enabled this can pass an invalid shape into TabView at Line 52.

Proposed fix
-    const tabContent = React.useMemo(() => {
+    const tabContent = React.useMemo<Record<string, React.ReactNode>>(() => {
         if (!tabs) {
             return {default: children};
         }
-        return typeof children === 'object' && children !== null ? children : {default: children};
+        if (
+            children !== null &&
+            typeof children === 'object' &&
+            !React.isValidElement(children) &&
+            !Array.isArray(children)
+        ) {
+            return children as Record<string, React.ReactNode>;
+        }
+        return {default: children};
     }, [tabs, children]);
...
-                    <TabView defaultTab={defaultTab} tabContent={tabContent as Record<string, React.ReactNode>} tabs={tabs} />
+                    <TabView defaultTab={defaultTab} tabContent={tabContent} tabs={tabs} />

Also applies to: 52-52

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/ui/SettingsPanel.tsx` around lines 34
- 39, When building tabContent in SettingsPanel (the tabContent const used by
TabView), ensure you only treat children as a tab-content map when it's a plain
object (not a React element or array). Replace the current typeof children ===
'object' check with a guard that verifies children !== null && typeof children
=== 'object' && !Array.isArray(children) && !React.isValidElement(children)
before returning children; otherwise return {default: children} so TabView
always receives a valid tab-content shape.
♻️ Duplicate comments (1)
packages/koenig-lexical/src/components/ui/TenorSelector.tsx (1)

132-139: ⚠️ Potential issue | 🔴 Critical

highlightPrev still missing early return after focusSearch().

The bug flagged in a previous review remains unfixed. When highlightedGif.index === 0, execution continues past focusSearch() to line 138, accessing gifs[-1] which returns undefined.

Proposed fix
     function highlightPrev() {
         if (highlightedGif!.index === 0) {
             // reached the beginning, focus the search bar
             focusSearch();
+            return;
         }

         setHighlightedGif(gifs[highlightedGif!.index - 1]);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/ui/TenorSelector.tsx` around lines 132
- 139, In highlightPrev(), when highlightedGif!.index === 0 you must return
immediately after calling focusSearch() to avoid executing
setHighlightedGif(gifs[highlightedGif!.index - 1]) which accesses gifs[-1];
update the function (referencing highlightPrev, highlightedGif, focusSearch,
setHighlightedGif, and gifs) to add an early return right after focusSearch() so
the out-of-bounds access is prevented.
🟡 Minor comments (24)
packages/koenig-lexical/demo/utils/useFileUpload.ts-49-66 (1)

49-66: ⚠️ Potential issue | 🟡 Minor

Potential runtime error if type is not a valid key.

If defaultValidator is called when type is an empty string (the default) or any string not present in fileTypes, line 53 will throw Cannot read properties of undefined (reading 'extensions'). The early return for type === 'file' doesn't guard against other invalid types.

🛡️ Proposed defensive check
 function defaultValidator(file: File): string | true {
     if (type === 'file') {
         return true;
     }
+    if (!fileTypes[type]) {
+        return true; // Accept all files for unknown types
+    }
     const extensions = fileTypes[type].extensions;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/demo/utils/useFileUpload.ts` around lines 49 - 66,
defaultValidator currently assumes fileTypes[type] exists and reads .extensions,
which will throw if type is empty or not a valid key; add a defensive guard in
defaultValidator to check that type is truthy and fileTypes[type] exists (and
has .extensions) before accessing it, and if the check fails either accept the
file (return true) or return a clear validation message; reference the
defaultValidator function and the fileTypes and type variables when making this
change.
packages/koenig-lexical/src/components/ui/cards/FileCard.tsx-177-181 (1)

177-181: ⚠️ Potential issue | 🟡 Minor

Non-null assertion on onFileChange could cause runtime crash.

onFileChange is typed as optional (onFileChange?: ...) in FileCardProps, but line 180 uses a non-null assertion (onFileChange!). If the EmptyFileCard branch renders and a user selects a file without onFileChange being provided, this will throw a runtime error.

🛡️ Proposed fix

Either make onFileChange required when rendering the empty state, or add a guard:

            <FileUploadForm
                fileInputRef={onFileInputRef}
                setFileInputRef={setFileInputRef}
-               onFileChange={onFileChange!}
+               onFileChange={onFileChange ?? (() => {})}
            />

Alternatively, if FileUploadForm can accept an optional handler, remove the assertion:

-               onFileChange={onFileChange!}
+               onFileChange={onFileChange}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/ui/cards/FileCard.tsx` around lines
177 - 181, The EmptyFileCard rendering uses a non-null assertion on the optional
prop onFileChange when passing it into FileUploadForm (see FileCard.tsx around
the FileUploadForm usage), which can crash at runtime; fix by either requiring
onFileChange when rendering the empty state or guarding the prop: update the
FileCard render logic to only pass onFileChange to FileUploadForm if it is
defined (or provide a no-op fallback handler) so that FileUploadForm always
receives a valid function, and ensure any prop types (FileCardProps or
FileUploadForm props) reflect the optional/required status consistently.
packages/koenig-lexical/src/components/ui/cards/FileCard.tsx-70-81 (1)

70-81: ⚠️ Potential issue | 🟡 Minor

Non-null assertions on optional handlers may cause runtime errors.

The handleFileTitle! and handleFileDesc! assertions assume these handlers are always provided when the TextInput components are rendered. While the conditional rendering (isEditing || title/desc) provides some protection, if isEditing is true but the handlers aren't provided, this will throw at runtime.

Consider adding defensive guards or making the handlers required when isEditing is expected to be true.

🛡️ Proposed fix with defensive guards
                                <TextInput
                                    className="h-[30px] bg-transparent text-lg font-bold leading-none tracking-tight text-black dark:text-grey-200"
                                    data-kg-file-card="fileTitle"
                                    maxLength={80}
                                    placeholder={titlePlaceholder}
                                    value={title}
-                                   onChange={handleFileTitle!}
+                                   onChange={handleFileTitle}
                                />
                            <TextInput
                                className="h-[26px] bg-transparent pb-1 text-[1.6rem] font-normal leading-none text-grey-700 placeholder:text-grey-500 dark:text-grey-300 dark:placeholder:text-grey-800"
                                data-kg-file-card="fileDescription"
                                maxLength={100}
                                placeholder={descPlaceholder}
                                value={desc}
-                               onChange={handleFileDesc!}
+                               onChange={handleFileDesc}
                            />

This requires TextInput to accept an optional onChange prop, which would be the cleaner approach.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/ui/cards/FileCard.tsx` around lines 70
- 81, The code uses non-null assertions on handleFileTitle and handleFileDesc
when rendering TextInput, which can throw if isEditing is true but handlers are
undefined; remove the "!" assertions and either guard the prop (e.g., only pass
onChange when handleFileTitle/handleFileDesc is defined) or provide a safe no-op
fallback, and update TextInput's props/type to accept an optional onChange so
the component compiles cleanly; reference the handleFileTitle, handleFileDesc,
TextInput, isEditing, title and desc symbols when making the changes.
packages/koenig-lexical/src/components/ui/TenorSelector.tsx-359-359 (1)

359-359: ⚠️ Potential issue | 🟡 Minor

Unsafe cast of error to string.

The error prop is typed as unknown but cast directly to string. If error is an Error object or other type, the Error component may not render it correctly.

Proposed fix: safely convert error to string
-                    {!!error && <div data-testid="tenor-selector-error"><Error error={error as string} /></div>}
+                    {!!error && <div data-testid="tenor-selector-error"><Error error={error instanceof Error ? error.message : String(error)} /></div>}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/ui/TenorSelector.tsx` at line 359, The
line in TenorSelector.tsx unsafely casts the unknown error to string; replace
that cast by normalizing error into a string before passing to the Error
component (e.g., check typeof error === 'string', else if error instanceof Error
use error.message, otherwise use String(error or 'Unknown error')). Update the
JSX around {!!error && <div data-testid="tenor-selector-error"><Error
error={...} /></div>} to pass the safely converted string instead of (error as
string).
packages/koenig-lexical/src/nodes/EmailCtaNode.tsx-83-93 (1)

83-93: ⚠️ Potential issue | 🟡 Minor

Use public getter for segment property to match other properties.

Line 90 accesses this.__segment directly while other base properties use public getters (this.alignment, this.buttonText, this.buttonUrl, this.showButton, this.showDividers). Change to this.segment for consistency.

Diff
                <EmailCtaNodeComponent
                    alignment={this.alignment as string}
                    buttonText={this.buttonText as string}
                    buttonUrl={this.buttonUrl as string}
                    htmlEditor={this.__htmlEditor}
                    htmlEditorInitialState={this.__htmlEditorInitialState as string | undefined}
                    nodeKey={this.getKey()}
-                   segment={this.__segment as string}
+                   segment={this.segment as string}
                    showButton={this.showButton as boolean}
                    showDividers={this.showDividers as boolean}
                />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/nodes/EmailCtaNode.tsx` around lines 83 - 93, The
code is directly accessing the private field this.__segment when rendering
EmailCtaNodeComponent; change it to use the public getter this.segment (matching
how alignment, buttonText, buttonUrl, showButton, and showDividers are used) so
replace the this.__segment prop passed to EmailCtaNodeComponent with
this.segment to keep property access consistent in the EmailCtaNode render
method.
packages/koenig-lexical/src/nodes/FileNodeComponent.tsx-64-74 (1)

64-74: ⚠️ Potential issue | 🟡 Minor

Non-null assertion on files could cause runtime error.

e.target.files can be null (e.g., if the user cancels the file dialog). The files! assertion on line 74 bypasses this check. Consider adding an early return guard.

Proposed fix
     const onFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
         const files = e.target.files;
+        if (!files || files.length === 0) {
+            return;
+        }
 
         // reset original src so it can be replaced with preview and upload progress
         editor.update(() => {
             const node = $getNodeByKey(nodeKey) as FileNode | null;
             if (!node) {return;}
             node.src = '';
         });
 
-        return await fileUploadHandler(Array.from(files!), nodeKey, editor, uploader.upload);
+        return await fileUploadHandler(Array.from(files), nodeKey, editor, uploader.upload);
     };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/nodes/FileNodeComponent.tsx` around lines 64 -
74, The handler onFileChange uses e.target.files with a non-null assertion
(files!) which can throw if the user cancels the dialog; add an early guard to
check if const files = e.target.files is null or has length 0 and return early
before calling fileUploadHandler, then pass Array.from(files) without the
non-null assertion; keep the existing editor.update block and use nodeKey,
editor and uploader.upload as before.
packages/koenig-lexical/demo/utils/fetchEmbed.ts-3-6 (1)

3-6: ⚠️ Potential issue | 🟡 Minor

Dead code: new URL() throws on invalid input, never returns falsy.

The check if (!urlObject) is unreachable because new URL(url) either succeeds and returns a valid URL object, or throws a TypeError for invalid URLs. The error should be caught in the existing try-catch instead.

Suggested fix
 export async function fetchEmbed(url: string, {type}: {type: string}) {
     console.log('fetchEmbed', {url, type});
-    const urlObject = new URL(url);
-    if (!urlObject) {
-        throw new Error('No URL specified.');
-    }
     await delay(process.env.NODE_ENV === 'test' ? 50 : 1500);
     try {
+        const urlObject = new URL(url);
         if (type === 'bookmark') {

Or wrap URL parsing in try-catch if you want a custom error message.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/demo/utils/fetchEmbed.ts` around lines 3 - 6, The
null-check after constructing the URL is dead code because new URL(url) throws
on invalid input; remove the unreachable if (!urlObject) branch in fetchEmbed
(or the function containing const urlObject = new URL(url)) and rely on the
existing try-catch to handle invalid URLs, or explicitly wrap the URL
construction in its own try-catch to throw a custom error message before
proceeding with the rest of the function.
packages/koenig-lexical/src/components/ui/TabView.tsx-14-15 (1)

14-15: ⚠️ Potential issue | 🟡 Minor

Guard the initial tab selection.

tabs[0].id will throw when tabs is empty, and an unknown defaultTab leaves the panel with no matching content. Validate the initial id before seeding state and bail out gracefully when there are no tabs.

💡 Suggested fix
 const TabView = ({tabs, defaultTab, tabContent}: TabViewProps) => {
-    const [activeTab, setActiveTab] = useState(defaultTab || tabs[0].id);
+    const initialTab = tabs.find(tab => tab.id === defaultTab)?.id ?? tabs[0]?.id;
+
+    if (!initialTab) {
+        return null;
+    }
+
+    const [activeTab, setActiveTab] = useState(initialTab);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/ui/TabView.tsx` around lines 14 - 15,
The TabView component seeds its state with defaultTab or tabs[0].id which
crashes when tabs is empty or selects a defaultTab that doesn't exist; update
TabView (props TabViewProps, variables activeTab and setActiveTab, and rendering
that uses tabContent) to first validate tabs is non-empty and that defaultTab
exists in tabs (e.g., find by id), initialize activeTab to a safe value (the
validated defaultTab id or the first tab's id) and if no tabs exist return
early/empty UI to bail out gracefully so the panel never tries to render missing
content.
packages/koenig-lexical/src/nodes/header/v2/HeaderNodeComponent.tsx-143-152 (1)

143-152: ⚠️ Potential issue | 🟡 Minor

Validate files before clearing the current image.

This handler accepts null, but Array.from(files!) still assumes a value is present. If a caller passes null or an empty selection here, the current background image is cleared before the upload path fails.

💡 Suggested fix
 const handleImageChange = async (files: File[] | FileList | null) => {
+    if (!files || files.length === 0) {
+        return;
+    }
+
     // reset original src so it can be replaced with preview and upload progress
     editor.update(() => {
         const node = $getHeaderNodeByKey(nodeKey);
         if (!node) {return;}
         node.backgroundImageSrc = '';
     });
 
-    const result = await backgroundImageUploadHandler(Array.from(files!), imageUploader.upload);
+    const result = await backgroundImageUploadHandler(Array.from(files), imageUploader.upload);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/nodes/header/v2/HeaderNodeComponent.tsx` around
lines 143 - 152, The handler handleImageChange clears the header node's
backgroundImageSrc before validating files and may wipe the image when files is
null/empty; first validate that files is non-null and has at least one file
(and/or FileList.length > 0) and return early if not, then proceed to call
backgroundImageUploadHandler and only update
node.backgroundImageSrc/preview/upload progress after a successful files array
conversion and upload start; locate the logic in HeaderNodeComponent's
handleImageChange and move the editor.update that clears backgroundImageSrc to
after the files checks (or guard it with a check on files.length) so the image
is not cleared on null/empty input.
packages/koenig-lexical/src/nodes/ImageNodeComponent.tsx-166-177 (1)

166-177: ⚠️ Potential issue | 🟡 Minor

Bail out before blanking the node when no files were selected.

e.target.files is nullable here, but the code clears node.src before passing files! into the upload handler. A null or empty selection path will leave the current image blank with no replacement.

💡 Suggested fix
     const onFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
         const files = e.target.files;
+
+        if (!files || files.length === 0) {
+            return;
+        }
 
         // reset original src so it can be replaced with preview and upload progress
         editor.update(() => {
             const node = $getImageNodeByKey(nodeKey);
            if (!node) {return;}
             if (!node) {return;}
             node.src = '';
         });
 
-        return await imageUploadHandler(files!, nodeKey, editor, imageUploader.upload);
+        return await imageUploadHandler(files, nodeKey, editor, imageUploader.upload);
     };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/nodes/ImageNodeComponent.tsx` around lines 166 -
177, The onFileChange handler clears node.src before verifying files exist,
which blanks the image when no file was selected; modify onFileChange to check
e.target.files (and files.length) and return early if null/empty before calling
editor.update or blanking the node, then only call imageUploadHandler(files,
nodeKey, editor, imageUploader.upload) when files are present; reference the
onFileChange function, $getImageNodeByKey, node.src, imageUploadHandler and
imageUploader.upload to locate where to add the early-return guard.
packages/koenig-lexical/src/components/ui/cards/CodeBlockCard.tsx-197-213 (1)

197-213: ⚠️ Potential issue | 🟡 Minor

Hide the language input semantically, not just visually.

When showLanguage is false the field only gets opacity-0, so it can still take focus and intercept clicks in the editor’s top-right corner. Making the wrapper relative and disabling pointer/tab interaction when hidden avoids that.

💡 Suggested fix
-        <div className="not-kg-prose min-h-[170px]">
+        <div className="not-kg-prose relative min-h-[170px]">
             <CodeMirror
                 autoFocus={true} // autofocus the editor whenever it is rendered
                 basicSetup={false} // basic setup includes unnecessary extensions
                 extensions={extensions}
                 value={code}
@@
             <input
+                aria-hidden={!showLanguage}
                 aria-label="Code card language"
-                className={`z-999 absolute right-1.5 top-1.5 w-1/5 rounded-md border border-grey-300 px-2 py-1 font-sans text-[1.3rem] leading-4 text-grey-900 transition-opacity focus-visible:outline-none dark:border-grey-900 dark:text-grey-400 ${showLanguage ? 'opacity-100' : 'opacity-0'}`}
+                className={`z-999 absolute right-1.5 top-1.5 w-1/5 rounded-md border border-grey-300 px-2 py-1 font-sans text-[1.3rem] leading-4 text-grey-900 transition-opacity focus-visible:outline-none dark:border-grey-900 dark:text-grey-400 ${showLanguage ? 'opacity-100' : 'pointer-events-none opacity-0'}`}
                 data-testid="code-card-language"
                 placeholder="Language..."
+                tabIndex={showLanguage ? 0 : -1}
                 type="text"
                 value={language}
                 onChange={onLanguageChange}
             />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/ui/cards/CodeBlockCard.tsx` around
lines 197 - 213, The language input is only visually hidden via opacity, so when
showLanguage is false it still receives focus and intercepts clicks; update the
CodeBlockCard component so the wrapper div (the element with className
"not-kg-prose min-h-[170px]") is positioned relative and the input element
(data-testid="code-card-language") is made non-interactive when hidden: add
conditional classes/attributes such that when showLanguage is false the input
gets pointer-events-none (or disabled), tabIndex={-1}, and aria-hidden="true"
(and when true restore tabIndex=0 and remove aria-hidden), while keeping the
existing visual opacity logic to preserve appearance. This targets the
CodeBlockCard input and surrounding div to prevent the input from stealing
focus/clicks when hidden.
packages/koenig-lexical/src/components/ui/KeyboardSelection.tsx-39-43 (1)

39-43: ⚠️ Potential issue | 🟡 Minor

Potential undefined passed to onSelect when items is empty.

If items is an empty array, items[selectedIndex] will be undefined, but onSelect expects unknown (implying a valid item). Consider guarding against empty arrays.

Proposed fix
         if (event.key === 'Enter') {
             event.preventDefault();
             event.stopPropagation();
-            onSelect(items[selectedIndex]);
+            if (items.length > 0) {
+                onSelect(items[selectedIndex]);
+            }
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/ui/KeyboardSelection.tsx` around lines
39 - 43, The Enter key handler in KeyboardSelection.tsx may call
onSelect(items[selectedIndex]) when items is empty or selectedIndex is out of
range; update the handler in the component (the block that checks if (event.key
=== 'Enter')) to first verify items.length > 0 and that selectedIndex is a valid
index (e.g., selectedIndex >= 0 && selectedIndex < items.length) before calling
onSelect, and bail out (or optionally call a safe fallback) if the check fails
so undefined is never passed to onSelect.
packages/koenig-lexical/demo/RestrictedContentDemo.tsx-89-89 (1)

89-89: ⚠️ Potential issue | 🟡 Minor

Non-null assertion on ref may cause runtime error.

containerRef.current! assumes the ref is always attached, but if this code runs before the ref is set or after unmount, it will throw. Consider adding a null check.

Proposed fix
                 //scroll to the bottom of the container
-                containerRef.current!.scrollTop = containerRef.current!.scrollHeight;
+                if (containerRef.current) {
+                    containerRef.current.scrollTop = containerRef.current.scrollHeight;
+                }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/demo/RestrictedContentDemo.tsx` at line 89, The
current assignment uses a non-null assertion on containerRef.current (in
RestrictedContentDemo) which can throw if the ref isn't attached; update the
code that sets containerRef.current!.scrollTop =
containerRef.current!.scrollHeight to first guard that containerRef.current
exists (e.g., retrieve into a local const el = containerRef.current and return
or skip if el is null) and only then set el.scrollTop = el.scrollHeight; this
ensures safe access to scrollTop/scrollHeight and avoids runtime errors when the
ref is undefined or after unmount.
packages/koenig-lexical/src/components/ui/FloatingLinkToolbar.tsx-83-84 (1)

83-84: ⚠️ Potential issue | 🟡 Minor

Selection set even when range is invalid.

$setSelection(selection) and onEditLink({href}) are called unconditionally after the if block. If firstChild/lastChild are null or not TextNodes, an empty/invalid selection is set and onEditLink is still invoked, which may cause unexpected behavior.

Proposed fix to only proceed when selection is valid
             const firstChild = linkNode.getFirstChild();
             const lastChild = linkNode.getLastChild();
-            if (firstChild && lastChild) {
+            if (firstChild && lastChild && $isTextNode(firstChild) && $isTextNode(lastChild)) {
                 selection.setTextNodeRange(
                     firstChild,
                     0,
                     lastChild,
                     lastChild.getTextContentSize()
                 );
+                $setSelection(selection);
+                onEditLink({href});
             }
-            $setSelection(selection);
-            onEditLink({href});
         });
     };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/ui/FloatingLinkToolbar.tsx` around
lines 83 - 84, The code currently calls $setSelection(selection) and
onEditLink({href}) unconditionally which can set an invalid selection when
firstChild/lastChild are null or not TextNodes; update the logic in
FloatingLinkToolbar so you only call $setSelection(selection) and
onEditLink({href}) when the computed selection is valid (i.e., firstChild and
lastChild exist and are TextNodes and the selection range is non-empty/valid).
Concretely, add a guard around the existing calls (or move them inside the
existing if block that verifies firstChild/lastChild), referencing the existing
variables/operations: firstChild, lastChild, selection, $setSelection, and
onEditLink to ensure those functions are invoked only after validation.
packages/koenig-lexical/src/components/ui/FloatingLinkToolbar.tsx-73-82 (1)

73-82: ⚠️ Potential issue | 🟡 Minor

Add type guards for setTextNodeRange arguments.

linkNode.getFirstChild() and getLastChild() return LexicalNode | null, but setTextNodeRange expects TextNode arguments. If a link contains non-text content, this will cause a runtime error. Add $isTextNode guards before calling setTextNodeRange:

Proposed fix
+import {$isTextNode} from 'lexical';
...
             const firstChild = linkNode.getFirstChild();
             const lastChild = linkNode.getLastChild();
-            if (firstChild && lastChild) {
+            if (firstChild && lastChild && $isTextNode(firstChild) && $isTextNode(lastChild)) {
                 selection.setTextNodeRange(
                     firstChild,
                     0,
                     lastChild,
                     lastChild.getTextContentSize()
                 );
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/ui/FloatingLinkToolbar.tsx` around
lines 73 - 82, The code calls selection.setTextNodeRange using
linkNode.getFirstChild() and getLastChild() which return LexicalNode|null but
setTextNodeRange requires TextNode; add type guards using $isTextNode to ensure
both firstChild and lastChild are TextNode before calling
selection.setTextNodeRange (i.e., check $isTextNode(firstChild) &&
$isTextNode(lastChild) and only then pass them to selection.setTextNodeRange),
so non-text children inside the link won't cause a runtime error.
packages/koenig-lexical/src/components/ui/cards/SignupCard.tsx-520-520 (1)

520-520: ⚠️ Potential issue | 🟡 Minor

Additional non-null assertions on optional props.

Similar to line 295, onFileChange! (line 520) and handleLabels! (line 567) use non-null assertions on optional props. These should either be made required or guarded.

Also applies to: 567-567

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/ui/cards/SignupCard.tsx` at line 520,
The code is using non-null assertions on optional props (onFileChange! and
handleLabels!) in SignupCard; either make these props required in the
SignupCardProps/type or guard their use. Fix by updating the prop type to mark
onFileChange and handleLabels as required, or remove the "!" and add runtime
guards where used (e.g., only pass onFileChange to the child or call
handleLabels when it's defined). Reference the SignupCard component and the prop
names onFileChange and handleLabels to locate and update the prop interface and
all usages accordingly.
packages/koenig-lexical/src/components/ui/cards/EmbedCard.tsx-117-127 (1)

117-127: ⚠️ Potential issue | 🟡 Minor

Non-null assertions in handleLoad may fail for cross-origin iframes.

iframeRef.current!.contentDocument!.body and iframeRef.current!.contentWindow!.document assume the iframe's content is accessible. For cross-origin content (e.g., some embeds), contentDocument will be null due to same-origin policy, causing a runtime error.

🛡️ Proposed defensive guards
 const handleLoad = () => {
-    const iframeBody = iframeRef.current!.contentDocument!.body;
+    const iframeBody = iframeRef.current?.contentDocument?.body;
+    if (!iframeBody) {
+        return;
+    }
     // apply styles
     iframeBody.style.display = 'flex';
     iframeBody.style.margin = '0';
     iframeBody.style.justifyContent = 'center';
     // resize first load
     handleResize();
     // start listening to mutations when the iframe content is loaded
-    mutationObserver.observe(iframeRef.current!.contentWindow!.document, config);
+    const contentDoc = iframeRef.current?.contentWindow?.document;
+    if (contentDoc) {
+        mutationObserver.observe(contentDoc, config);
+    }
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/ui/cards/EmbedCard.tsx` around lines
117 - 127, The handleLoad function uses non-null assertions on
iframeRef.current!.contentDocument!.body and
iframeRef.current!.contentWindow!.document which will throw for cross-origin
embeds; update handleLoad to defensively check iframeRef.current exists and that
iframeRef.current.contentDocument and iframeRef.current.contentWindow are
non-null and accessible before touching body styles or calling
mutationObserver.observe, and only call handleResize and start observing when
those checks pass (otherwise skip styling/observing for cross-origin iframes);
replace uses of non-null assertions with these safe guards and bail out early if
the iframe is cross-origin.
packages/koenig-lexical/src/components/ui/cards/SignupCard.tsx-295-295 (1)

295-295: ⚠️ Potential issue | 🟡 Minor

Non-null assertion on optional onFileChange prop.

onFileChange is declared as optional (onFileChange?: ...) in SignupCardProps, but it's used with a non-null assertion (onFileChange!). If the parent component doesn't pass onFileChange, this will throw a runtime error.

Either make onFileChange required in the interface, or guard the MediaUploader rendering with a check for onFileChange.

🛡️ Option 1: Make prop required
 interface SignupCardProps {
     // ...
-    onFileChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
+    onFileChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
     // ...
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/ui/cards/SignupCard.tsx` at line 295,
SignupCard uses the optional prop onFileChange with a non-null assertion
(onFileChange!) when rendering MediaUploader, which can crash if the parent
omits it; update either the SignupCardProps to make onFileChange required or
(preferred) guard the MediaUploader render/prop by checking if onFileChange is
defined before passing it (e.g., only render MediaUploader or pass the prop when
onFileChange !== undefined) so that SignupCard and MediaUploader never receive a
forced-null value.
packages/koenig-lexical/src/nodes/BookmarkNodeComponent.tsx-146-149 (1)

146-149: ⚠️ Potential issue | 🟡 Minor

Duplicate null guard should be removed.

Same issue as above — lines 148-149 contain duplicate if (!node) {return;} statements with inconsistent indentation on line 148.

🧹 Proposed fix
         editor.update(() => {
             const node = $getBookmarkNodeByKey(nodeKey);
-           if (!node) {return;}
             if (!node) {return;}
             node.url = response.url;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/nodes/BookmarkNodeComponent.tsx` around lines 146
- 149, In BookmarkNodeComponent.tsx inside the editor.update callback where you
call $getBookmarkNodeByKey(nodeKey), remove the duplicate null-check so only a
single if (!node) { return; } remains (currently duplicated on two adjacent
lines) and correct the indentation to match surrounding code; locate the checks
near the call to $getBookmarkNodeByKey and keep one properly indented guard
before using the node.
packages/koenig-lexical/src/components/KoenigComposableEditor.tsx-135-135 (1)

135-135: ⚠️ Potential issue | 🟡 Minor

Remove empty JSX expression.

The { } expression on line 135 appears to be a leftover from refactoring and serves no purpose.

🧹 Proposed fix
-            { }
             {floatingAnchorElem && (<FloatingToolbarPlugin anchorElem={floatingAnchorElem} hiddenFormats={hiddenFormats} isSnippetsEnabled={isSnippetsEnabled} />)}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/KoenigComposableEditor.tsx` at line
135, Remove the stray empty JSX expression "{ }" inside the
KoenigComposableEditor component JSX (file: KoenigComposableEditor.tsx) which is
a no-op leftover from refactoring; locate the JSX render/return block inside the
KoenigComposableEditor function/class and delete the empty expression so the JSX
tree contains only meaningful nodes and no empty expressions.
packages/koenig-lexical/src/components/ui/InputList.tsx-120-123 (1)

120-123: ⚠️ Potential issue | 🟡 Minor

Type mismatch: onChange called with extra argument.

When onSelect is undefined, onChange is called with typedItem.type as a second argument, but onChange is typed as (value: string) => void. While JavaScript ignores extra arguments, this indicates a type design issue.

🔧 Proposed fix
     const onSelectEvent = (item: unknown) => {
         const typedItem = item as {value: string; type?: string};
-        (onSelect || onChange)(typedItem.value, typedItem.type);
+        if (onSelect) {
+            onSelect(typedItem.value, typedItem.type);
+        } else {
+            onChange(typedItem.value);
+        }
     };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/ui/InputList.tsx` around lines 120 -
123, onSelectEvent currently calls (onSelect || onChange)(typedItem.value,
typedItem.type) which passes a second arg to onChange even though onChange is
typed as (value: string) => void; change the call so it respects each handler's
signature: if onSelect is defined call onSelect(typedItem.value,
typedItem.type), otherwise call onChange(typedItem.value). Update the
onSelectEvent implementation to explicitly branch on onSelect rather than using
the || shorthand to avoid the type mismatch (referencing onSelectEvent,
onSelect, onChange, and typedItem).
packages/koenig-lexical/src/nodes/BookmarkNodeComponent.tsx-120-123 (1)

120-123: ⚠️ Potential issue | 🟡 Minor

Duplicate null guard should be removed.

Lines 122-123 contain two identical if (!node) {return;} statements. The second check is redundant and appears to be a copy-paste error. Additionally, line 122 has inconsistent indentation (3 spaces instead of 4).

🧹 Proposed fix
         editor.update(() => {
             const node = $getBookmarkNodeByKey(nodeKey);
-           if (!node) {return;}
             if (!node) {return;}
             node.url = href;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/nodes/BookmarkNodeComponent.tsx` around lines 120
- 123, Remove the duplicate null guard inside the editor.update callback: keep a
single check after obtaining const node = $getBookmarkNodeByKey(nodeKey) and
delete the redundant if (!node) { return; } that follows it; also correct the
indentation of the remaining guard to match surrounding code (use 4 spaces).
This change affects the editor.update(...) callback in BookmarkNodeComponent.tsx
where $getBookmarkNodeByKey and node are used.
packages/koenig-lexical/src/components/KoenigComposableEditor.tsx-87-88 (1)

87-88: ⚠️ Potential issue | 🟡 Minor

Type mismatch: passing serialized JSON where EditorState is expected.

Line 88 passes json (a serialized JSON object from .toJSON()) to sharedOnChange, which expects an EditorState instance according to the SharedOnChangeContext callback signature. The cast as unknown as import('lexical').EditorState works around the type mismatch but is semantically incorrect and misleading. The callback signature should accept the actual JSON type being passed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/KoenigComposableEditor.tsx` around
lines 87 - 88, The code is serializing primaryEditorState via
primaryEditorState.toJSON() and casting that JSON to
import('lexical').EditorState when calling sharedOnChange, which is a type
mismatch; either pass the real EditorState instance to sharedOnChange or change
the SharedOnChangeContext callback type to accept the serialized shape. Fix by
updating the sharedOnChange/SharedOnChangeContext signature to accept the JSON
type returned by primaryEditorState.toJSON() (e.g., the concrete serialized
editor state type or a Record<string, unknown>/SerializedEditorState alias) and
adjust all call sites (including the call in KoenigComposableEditor where you
currently call sharedOnChange(json, editor, new Set<string>())) to use that new
serialized type, or alternatively stop calling .toJSON() and pass the actual
EditorState object into sharedOnChange so the existing EditorState type remains
correct.
packages/koenig-lexical/src/components/ui/SettingsPanel.tsx-373-377 (1)

373-377: ⚠️ Potential issue | 🟡 Minor

dataTestId exists in props but is not wired in MediaUploadSetting.

MediaUploadSettingProps includes dataTestId (Line 376), but Line 400 does not destructure it and the container test id is hardcoded. This makes the prop ineffective.

Proposed fix
-export function MediaUploadSetting({className, imgClassName, label, hideLabel, onFileChange, isDraggedOver, placeholderRef, src, alt, isLoading, errors = [], progress, onRemoveMedia, icon, desc, size, type, stacked, borderStyle, mimeTypes, isPinturaEnabled, openImageEditor, setFileInputRef}: MediaUploadSettingProps) {
+export function MediaUploadSetting({className, imgClassName, dataTestId, label, hideLabel, onFileChange, isDraggedOver, placeholderRef, src, alt, isLoading, errors = [], progress, onRemoveMedia, icon, desc, size, type, stacked, borderStyle, mimeTypes, isPinturaEnabled, openImageEditor, setFileInputRef}: MediaUploadSettingProps) {
     return (
-        <div className={clsx(className, !stacked && 'flex justify-between gap-3')} data-testid="media-upload-setting">
+        <div className={clsx(className, !stacked && 'flex justify-between gap-3')} data-testid={dataTestId ?? 'media-upload-setting'}>

Also applies to: 400-403

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/ui/SettingsPanel.tsx` around lines 373
- 377, The MediaUploadSetting component doesn't use the dataTestId prop declared
in MediaUploadSettingProps; update the MediaUploadSetting function signature to
destructure dataTestId (alongside className, imgClassName, label) and replace
the hardcoded data-testid on the container element with the passed-in dataTestId
(optionally keep a fallback string if needed) so the prop is actually wired and
controls the test id.

Comment on lines +12 to +16
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest']
],
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check typescript-eslint version
fd -t f 'package.json' packages/koenig-lexical --exec cat {} | jq '.dependencies["typescript-eslint"] // .devDependencies["typescript-eslint"]'

Repository: TryGhost/Koenig

Length of output: 67


🏁 Script executed:

cat -n packages/koenig-lexical/eslint.config.js

Repository: TryGhost/Koenig

Length of output: 1925


🏁 Script executed:

# Check typescript-eslint v8 documentation behavior for configs.recommended
web_search
What does tseslint.configs.recommended return in typescript-eslint v8

Repository: TryGhost/Koenig

Length of output: 152


🌐 Web query:

typescript-eslint v8 tseslint.configs.recommended array flat config

💡 Result:

In typescript-eslint v8, tseslint.configs.recommended is a flat-config array, so in an eslint.config.(m)js you typically pass it to defineConfig(...) (recommended) or spread it into your exported array.

Option A (recommended): defineConfig(...)

// eslint.config.mjs
import eslint from '@eslint/js';
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';

export default defineConfig(
  eslint.configs.recommended,
  tseslint.configs.recommended,
);

[1]

Option B: export a flat-config array (note the spread)

// eslint.config.mjs
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';

export default [
  eslint.configs.recommended,
  ...tseslint.configs.recommended,
];

[2]

If you want “recommended” to apply only to TS files

Using tseslint.config(...) (their helper) lets you attach files + extends cleanly:

import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config({
  files: ['**/*.{ts,tsx,mts,cts}'],
  extends: [eslint.configs.recommended, tseslint.configs.recommended],
});

[2]

Sources: typescript-eslint “Getting Started” flat config quickstart [1], typescript-eslint package docs (flat config array + spread + tseslint.config helper) [2], shared configs (recommended) docs [3].

[1] https://typescript-eslint.io/getting-started/
[2] https://typescript-eslint.io/packages/typescript-eslint/
[3] https://typescript-eslint.io/users/configs/


🏁 Script executed:

# Check if there's a way to test the eslint config in the repo
cd packages/koenig-lexical && ls -la | head -20

Repository: TryGhost/Koenig

Length of output: 1236


🏁 Script executed:

# Check package.json for test/lint scripts
cat packages/koenig-lexical/package.json | grep -A 5 '"scripts"'

Repository: TryGhost/Koenig

Length of output: 526


🏁 Script executed:

# Check ESLint flat config spec to understand if extends array can handle nested arrays
cat packages/koenig-lexical/package.json | jq '.devDependencies | keys[] | select(. | contains("eslint"))'

Repository: TryGhost/Koenig

Length of output: 212


🏁 Script executed:

# Try to see if there are any build or lint errors documented
cd packages/koenig-lexical && cat package.json | jq '.devDependencies'

Repository: TryGhost/Koenig

Length of output: 2721


Spread tseslint.configs.recommended in the extends array.

In typescript-eslint v8, tseslint.configs.recommended is a flat-config array. Placing it directly in the extends array creates a nested array structure, which is invalid. Use the spread operator instead:

extends: [
    js.configs.recommended,
    ...tseslint.configs.recommended,
    reactHooks.configs['recommended-latest']
],
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/eslint.config.js` around lines 12 - 16, The extends
array currently includes tseslint.configs.recommended directly which yields a
nested array; update the extends entry in eslint.config.js so you spread
tseslint.configs.recommended into the array (use
...tseslint.configs.recommended) alongside js.configs.recommended and
reactHooks.configs['recommended-latest'] to produce a flat extends array.

Comment on lines +110 to 124
const getItem = (item: unknown, selected: boolean, onMouseOver: () => void, scrollIntoView: boolean) => {
const typedItem = item as {value: string; label: string; highlight?: boolean; Icon?: React.ComponentType; MetaIcon?: React.ComponentType; metaText?: string; metaIconTitle?: string};
return (
<LinkInputSearchItem
key={item.value}
key={typedItem.value}
dataTestId={testId}
highlightString={query}
item={item}
item={typedItem}
scrollIntoView={scrollIntoView}
selected={selected}
onClick={onSelect}
onClick={onSelect as () => void}
onMouseOver={onMouseOver}
/>
);
};
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Verify LinkInputSearchItem's onClick signature to confirm the fix

ast-grep --pattern 'function LinkInputSearchItem($$$) {
  $$$
}'

rg -n 'onClick' packages/koenig-lexical/src/components/ui/LinkInputSearchItem.tsx -A2 -B2

Repository: TryGhost/Koenig

Length of output: 3825


🏁 Script executed:

#!/bin/bash
# Find InputListItem definition and see how it handles onClick

rg -n 'InputListItem' packages/koenig-lexical/src/components/ui/ -l

fd -t f 'InputListItem' packages/koenig-lexical/src/

# Search for InputListItem interface/component definition
rg -B5 -A15 'interface InputListItemProps|type InputListItemProps|export.*InputListItem' packages/koenig-lexical/src/ -g '*.tsx' -g '*.ts'

Repository: TryGhost/Koenig

Length of output: 3234


🏁 Script executed:

#!/bin/bash
# Search for all uses of LinkInputSearchItem to understand if the () => void signature is intentional

rg -n 'LinkInputSearchItem' packages/koenig-lexical/src/ -g '*.tsx' -g '*.ts' --type-add 'tsx:*.tsx' --type-add 'ts:*.ts'

# Also check the LinkInputSearchItem props definition specifically
rg -B2 -A8 'interface LinkInputSearchItemProps' packages/koenig-lexical/src/components/ui/LinkInputSearchItem.tsx

Repository: TryGhost/Koenig

Length of output: 1424


🏁 Script executed:

#!/bin/bash
# Check how UrlSearchInput uses LinkInputSearchItem
rg -B5 -A10 '<LinkInputSearchItem' packages/koenig-lexical/src/components/ui/UrlSearchInput.tsx

# Check how LinkInputWithSearch uses LinkInputSearchItem  
rg -B5 -A10 '<LinkInputSearchItem' packages/koenig-lexical/src/components/ui/LinkInputWithSearch.tsx

Repository: TryGhost/Koenig

Length of output: 1228


🏁 Script executed:

#!/bin/bash
# Get the full LinkInputSearchItem interface definition
rg -B2 -A15 'interface LinkInputSearchItemProps' packages/koenig-lexical/src/components/ui/LinkInputSearchItem.tsx -A 20

Repository: TryGhost/Koenig

Length of output: 792


Fix onClick handler to properly receive the item parameter.

LinkInputSearchItemProps incorrectly defines onClick?: () => void while InputListItem (which it wraps) expects onClick?: (item: unknown) => void and calls it with the item. The cast onSelect as () => void masks this type mismatch. The proposed fix using onClick={() => onSelect(typedItem)} correctly captures and passes the item to the handler.

Note: This type mismatch affects all three callers of LinkInputSearchItem (AtLinkResultsPopup, UrlSearchInput, LinkInputWithSearch), suggesting the root cause is LinkInputSearchItemProps.onClick being incorrectly typed. Ideally, the prop type should be fixed to onClick?: (item: unknown) => void to resolve this systematically across all usage sites.

🐛 Proposed fix
         return (
             <LinkInputSearchItem
                 key={typedItem.value}
                 dataTestId={testId}
                 highlightString={query}
                 item={typedItem}
                 scrollIntoView={scrollIntoView}
                 selected={selected}
-                onClick={onSelect as () => void}
+                onClick={() => onSelect(typedItem)}
                 onMouseOver={onMouseOver}
             />
         );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const getItem = (item: unknown, selected: boolean, onMouseOver: () => void, scrollIntoView: boolean) => {
const typedItem = item as {value: string; label: string; highlight?: boolean; Icon?: React.ComponentType; MetaIcon?: React.ComponentType; metaText?: string; metaIconTitle?: string};
return (
<LinkInputSearchItem
key={item.value}
key={typedItem.value}
dataTestId={testId}
highlightString={query}
item={item}
item={typedItem}
scrollIntoView={scrollIntoView}
selected={selected}
onClick={onSelect}
onClick={onSelect as () => void}
onMouseOver={onMouseOver}
/>
);
};
const getItem = (item: unknown, selected: boolean, onMouseOver: () => void, scrollIntoView: boolean) => {
const typedItem = item as {value: string; label: string; highlight?: boolean; Icon?: React.ComponentType; MetaIcon?: React.ComponentType; metaText?: string; metaIconTitle?: string};
return (
<LinkInputSearchItem
key={typedItem.value}
dataTestId={testId}
highlightString={query}
item={typedItem}
scrollIntoView={scrollIntoView}
selected={selected}
onClick={() => onSelect(typedItem)}
onMouseOver={onMouseOver}
/>
);
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/ui/AtLinkResultsPopup.tsx` around
lines 110 - 124, The onClick handler is currently mismatched:
LinkInputSearchItemProps defines onClick?: () => void but InputListItem and
callers expect onClick?: (item: unknown) => void, and getItem in
AtLinkResultsPopup casts onSelect to () => void; fix by updating the prop type
on LinkInputSearchItemProps to onClick?: (item: unknown) => void (so
UrlSearchInput, LinkInputWithSearch and AtLinkResultsPopup align) and update
getItem (in AtLinkResultsPopup) to pass the selected item into the handler
(e.g., call onSelect(typedItem) via an arrow) instead of casting onSelect;
ensure LinkInputSearchItem and its uses accept and forward the item parameter
correctly.

Comment on lines +45 to +46
onFileChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
setFileInputRef: (ref: React.RefObject<HTMLInputElement | null>) => void;
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C3 '\bonFileChange\b' \
  packages/koenig-lexical/src/components/ui/AudioUploadForm.tsx \
  packages/koenig-lexical/src/components/ui/ImageUploadForm.tsx \
  packages/koenig-lexical/src/components/ui/cards/AudioCard.tsx

Repository: TryGhost/Koenig

Length of output: 11169


Make onFileChange handlers required in component props interfaces.

Lines 45, 98, 194 declare onFileChange as optional (?), but lines 84 and 177 use non-null assertions (!) when passing to AudioUploadForm. Both AudioUploadForm (line 4 of AudioUploadForm.tsx) and ImageUploadForm (line 4 of ImageUploadForm.tsx) require this handler—they invoke it directly in the form's onChange without guards. If the handler is omitted, file selection will crash at runtime. Make these handlers required in the prop interfaces instead of using type assertions.

Proposed fix
 interface EmptyAudioCardProps {
     audioUploader: FileUploader;
     audioMimeTypes?: string[];
-    onFileChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
+    onFileChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
     setFileInputRef: (ref: React.RefObject<HTMLInputElement | null>) => void;
     audioDragHandler?: DragHandler;
 }

 interface AudioThumbnailProps {
@@
-    onFileChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
+    onFileChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
@@
 interface AudioCardProps {
@@
-    onAudioFileChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
-    onThumbnailFileChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
+    onAudioFileChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
+    onThumbnailFileChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
@@
-                    onFileChange={onFileChange!}
+                    onFileChange={onFileChange}
@@
-                    onFileChange={onFileChange!}
+                    onFileChange={onFileChange}

Also applies to: 84-85, 98-99, 177-178, 278-279

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/ui/cards/AudioCard.tsx` around lines
45 - 46, The onFileChange prop is declared optional but later passed with
non-null assertions to AudioUploadForm and ImageUploadForm, which call it
unguarded; remove the optionality by making onFileChange required in the
component props interfaces (replace onFileChange?: ... with onFileChange: ...)
for the relevant components and update any related prop types (e.g., where
setFileInputRef is declared) and then remove the non-null assertion operators
(!) where onFileChange is passed into AudioUploadForm and ImageUploadForm so the
types align and the handler is guaranteed present.

Comment on lines +329 to +342
interface ColorPickerSettingProps {
label: string;
isExpanded?: boolean;
onSwatchChange: (value: string) => void;
onPickerChange: (value: string) => void;
onTogglePicker: (expanded: boolean) => void;
value?: string;
swatches: {hex?: string; accent?: boolean; transparent?: boolean; image?: boolean; title: string; customContent?: React.ReactNode}[];
eyedropper?: boolean;
hasTransparentOption?: boolean;
dataTestId?: string;
children?: React.ReactNode;
showChildren?: boolean;
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

showChildren is declared but ignored in ColorPickerSetting.

Line 341 exposes showChildren, but the component never reads it and always renders children (Line 365). That’s an API/behavior mismatch and can regress callers relying on hidden children state.

Proposed fix
-export function ColorPickerSetting({label, isExpanded, onSwatchChange, onPickerChange, onTogglePicker, value, swatches, eyedropper, hasTransparentOption, dataTestId, children}: ColorPickerSettingProps) {
+export function ColorPickerSetting({label, isExpanded, onSwatchChange, onPickerChange, onTogglePicker, value, swatches, eyedropper, hasTransparentOption, dataTestId, children, showChildren = true}: ColorPickerSettingProps) {
...
-                        {children}
+                        {showChildren ? children : null}
                     </ColorIndicator>

Also applies to: 344-366

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/ui/SettingsPanel.tsx` around lines 329
- 342, The prop showChildren on ColorPickerSettingProps is declared but never
used by the ColorPickerSetting component; update the ColorPickerSetting
component to conditionally render the children based on the showChildren prop
(falling back to current behavior when showChildren is undefined), i.e., change
the unconditional children render to something like: if showChildren is true
render children, if false do not render, and if undefined preserve existing
behavior; ensure you reference the prop name showChildren in the component’s
parameter destructuring and use it where children are rendered so callers can
control visibility.

import {Tooltip} from './Tooltip';

export const TOOLBAR_ICONS = {
export const TOOLBAR_ICONS: Record<string, React.ComponentType<React.SVGProps<SVGSVGElement>>> = {
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify all ToolbarMenuItem icon callsites and compare against TOOLBAR_ICONS keys.
rg -nP --type=tsx '<ToolbarMenuItem\b[^>]*\bicon=' -C2
rg -nP --type=tsx 'TOOLBAR_ICONS\s*=\s*\{' -A40

Repository: TryGhost/Koenig

Length of output: 114


🏁 Script executed:

#!/bin/bash
# Read the ToolbarMenu.tsx file to verify the current state
cat -n packages/koenig-lexical/src/components/ui/ToolbarMenu.tsx

Repository: TryGhost/Koenig

Length of output: 4690


🏁 Script executed:

#!/bin/bash
# Search for ToolbarMenuItem usage to verify icon values
rg 'ToolbarMenuItem' --max-count=20 -B2 -A2 packages/koenig-lexical/

Repository: TryGhost/Koenig

Length of output: 42422


🏁 Script executed:

#!/bin/bash
# Check FormatToolbar.tsx for ToolbarMenuItem icon props
cat packages/koenig-lexical/src/components/ui/FormatToolbar.tsx | head -100

Repository: TryGhost/Koenig

Length of output: 2938


🏁 Script executed:

#!/bin/bash
# Check FormatToolbar.tsx for ToolbarMenuItem icon props - full component render section
sed -n '100,200p' packages/koenig-lexical/src/components/ui/FormatToolbar.tsx

Repository: TryGhost/Koenig

Length of output: 3455


🏁 Script executed:

#!/bin/bash
# Check FormatToolbar.tsx for all ToolbarMenuItem icon props - continue reading
sed -n '180,250p' packages/koenig-lexical/src/components/ui/FormatToolbar.tsx

Repository: TryGhost/Koenig

Length of output: 2766


🏁 Script executed:

#!/bin/bash
# Search for all icon prop assignments to ToolbarMenuItem to ensure all are valid
rg 'icon=' packages/koenig-lexical/src --type ts --type tsx -o | grep -oP 'icon="\K[^"]+|icon=\{\K[^}]+(?=\})' | sort | uniq

Repository: TryGhost/Koenig

Length of output: 86


🏁 Script executed:

#!/bin/bash
# Search for all icon prop assignments more reliably
rg 'icon=' packages/koenig-lexical/src -A0 | grep -oP 'icon="\K[^"]+|icon=\{[^}]+\}' | sort | uniq

Repository: TryGhost/Koenig

Length of output: 319


🏁 Script executed:

#!/bin/bash
# Find the exact files using invalid icon values
rg 'icon="(download|heart)"' packages/koenig-lexical/src -B3 -A1

Repository: TryGhost/Koenig

Length of output: 1478


🏁 Script executed:

#!/bin/bash
# Check what UnsplashButton is and if it uses ToolbarMenuItem
cat packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashImage.tsx | head -40

Repository: TryGhost/Koenig

Length of output: 1220


🏁 Script executed:

#!/bin/bash
# Check UnsplashButton to see if it uses ToolbarMenuItem or TOOLBAR_ICONS
cat packages/koenig-lexical/src/components/ui/file-selectors/Unsplash/UnsplashButton.tsx

Repository: TryGhost/Koenig

Length of output: 1167


🏁 Script executed:

#!/bin/bash
# Check the dynamic icon assignments - find files with icon={icon} pattern
rg 'icon=\{icon\}' packages/koenig-lexical/src -B5 -A2

Repository: TryGhost/Koenig

Length of output: 2650


🏁 Script executed:

#!/bin/bash
# Check if ToolbarMenuItem is used with dynamic icon values like icon={icon}
rg 'ToolbarMenuItem' packages/koenig-lexical/src -A5 | grep -B3 'icon='

Repository: TryGhost/Koenig

Length of output: 20700


Constrain icon keys to prevent undefined component renders

Line 77 can render undefined if icon is not in TOOLBAR_ICONS. Typing icon as string and TOOLBAR_ICONS as Record<string, ...> allows any string key at compile time despite only specific icons being available.

Suggested fix
-export const TOOLBAR_ICONS: Record<string, React.ComponentType<React.SVGProps<SVGSVGElement>>> = {
+export const TOOLBAR_ICONS = {
     bold: BoldIcon,
     italic: ItalicIcon,
     headingTwo: HeadingTwoIcon,
     headingThree: HeadingThreeIcon,
     quote: QuoteIcon,
     quoteOne: QuoteOneIcon,
     quoteTwo: QuoteTwoIcon,
     link: LinkIcon,
     imgRegular: ImgRegularIcon,
     imgWide: ImgWideIcon,
     imgFull: ImgFullIcon,
     imgReplace: ImgReplaceIcon,
     add: AddIcon,
     edit: EditIcon,
     wand: WandIcon,
     visibility: EyeIcon,
     snippet: SnippetIcon,
     remove: TrashIcon
-};
+} as const satisfies Record<string, React.ComponentType<React.SVGProps<SVGSVGElement>>>;
+
+type ToolbarIconName = keyof typeof TOOLBAR_ICONS;
@@
-    icon: string;
+    icon: ToolbarIconName;

Also applies to: 64, 77

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/components/ui/ToolbarMenu.tsx` at line 21, The
icon lookup can return undefined because TOOLBAR_ICONS and the icon prop are
typed as unrestricted strings; define a narrow union type (e.g., type
ToolbarIconKey = keyof typeof TOOLBAR_ICONS or an explicit union of allowed
names) and update TOOLBAR_ICONS to be Record<ToolbarIconKey,
React.ComponentType<...>> and the icon prop/type usage (the prop named icon
where the map lookup occurs) to use ToolbarIconKey so the compiler prevents
unknown keys and eliminates potential undefined renders for TOOLBAR_ICONS and
the icon lookup expression.

Comment on lines +60 to +61
setupNestedEditor(this, '__callToActionHtmlEditor', {editor: dataset.callToActionHtmlEditor as LexicalEditor | undefined, nodes: BASIC_NODES});
setupNestedEditor(this, '__sponsorLabelHtmlEditor', {editor: dataset.sponsorLabelHtmlEditor as LexicalEditor | undefined, nodes: BASIC_NODES});
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Inspect setupNestedEditor definition and expected editor shape
rg -n -C3 --type=ts --type=tsx '\bsetupNestedEditor\b'

# 2) Find all places where unknown values are asserted to LexicalEditor
rg -n -C2 --type=ts --type=tsx 'as LexicalEditor \| undefined'

# 3) Trace CallToActionNode creation paths and dataset origins
rg -n -C3 --type=ts --type=tsx '\$createCallToActionNode\(|new CallToActionNode\(|INSERT_CALL_TO_ACTION_COMMAND'

Repository: TryGhost/Koenig

Length of output: 86


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Inspect setupNestedEditor definition and expected editor shape
rg -n -C3 'setupNestedEditor' --glob '*.ts' --glob '*.tsx'

# 2) Find all places where unknown values are asserted to LexicalEditor  
rg -n -C2 'as LexicalEditor' --glob '*.ts' --glob '*.tsx'

# 3) Trace CallToActionNode creation paths and dataset origins
rg -n -C3 '\$createCallToActionNode|new CallToActionNode|INSERT_CALL_TO_ACTION_COMMAND' --glob '*.ts' --glob '*.tsx'

Repository: TryGhost/Koenig

Length of output: 35075


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check CallToActionPlugin to see what calls $createCallToActionNode
cat -n packages/koenig-lexical/src/plugins/CallToActionPlugin.tsx | head -30

# Check if there's any validation in CallToActionNode constructor before setupNestedEditor
cat -n packages/koenig-lexical/src/nodes/CallToActionNode.tsx | head -80

# Check the SetupNestedEditorOptions type definition
rg -n -B5 -A10 'interface SetupNestedEditorOptions' --glob '*.ts' --glob '*.tsx'

# Check if dataset is validated/typed anywhere
rg -n 'createCallToActionNode\|INSERT_CALL_TO_ACTION_COMMAND' --glob '*.ts' --glob '*.tsx' | head -20

Repository: TryGhost/Koenig

Length of output: 6662


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Get the full setupNestedEditor implementation
cat -n packages/koenig-lexical/src/utils/nested-editors.ts

# Also check populateNestedEditor which is called on line 65-68 of CallToActionNode

Repository: TryGhost/Koenig

Length of output: 2661


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check how CallToActionNode instances are created/restored
# Look for deserialization or fromJSON patterns
rg -n 'fromJSON|deserialize|restore' packages/koenig-lexical/src/nodes/CallToActionNode.tsx

# Check what BaseCallToActionNode does in constructor
rg -n -A20 'class.*BaseCallToActionNode' --glob '*.ts' --glob '*.tsx' | head -40

# Check if there's any test data or mocking that might show invalid dataset usage
fd '*.test.*' packages/koenig-lexical/src | head -5
fd '*.spec.*' packages/koenig-lexical/src | head -5

# See if there are other places that dispatch INSERT_CALL_TO_ACTION_COMMAND
rg -n 'INSERT_CALL_TO_ACTION_COMMAND' --glob '*.ts' --glob '*.tsx' | grep -v 'import\|export'

Repository: TryGhost/Koenig

Length of output: 41


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check base class implementation 
rg -n 'class CallToActionNode' packages/koenig-lexical/src/nodes/CallToActionNode.tsx -A 5

# Check for fromJSON method anywhere in the codebase
rg -n 'fromJSON' packages/koenig-lexical/src/ --glob '*.ts' --glob '*.tsx'

# Look at how getDataset() is used - indicates serialization/deserialization
rg -n 'getDataset\|fromJSON' packages/koenig-lexical/src/nodes/ --glob '*.ts' --glob '*.tsx' -l

Repository: TryGhost/Koenig

Length of output: 332


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Try to find test files with different patterns
find packages/koenig-lexical/src -name "*.test.*" -o -name "*.spec.*" | head -5

# Look for any externals that provide dataset to these nodes
rg -n 'callToActionHtmlEditor' packages/koenig-lexical/src --glob '*.ts' --glob '*.tsx'

# See what happens if populateNestedEditor is called with a non-LexicalEditor
cat -n packages/koenig-lexical/src/utils/nested-editors.ts | sed -n '44,60p'

# Examine CallToActionNode.getDataset to understand the inverse mapping
cat -n packages/koenig-lexical/src/nodes/CallToActionNode.tsx | sed -n '72,85p'

Repository: TryGhost/Koenig

Length of output: 3236


Add runtime validation for LexicalEditor instances before passing to setupNestedEditor.

Type casting dataset.callToActionHtmlEditor as LexicalEditor | undefined only satisfies TypeScript and provides no runtime protection. If dataset contains a non-editor object that is truthy, setupNestedEditor will assign it directly (line 33 does only a truthy check), and later calls to getEditorState() (line 89) will fail at runtime.

This pattern affects multiple node files. Consider adding a type guard:

Suggested fix
+const isLexicalEditor = (value: unknown): value is LexicalEditor => {
+    return typeof value === 'object' &&
+        value !== null &&
+        'getEditorState' in value &&
+        typeof (value as {getEditorState?: unknown}).getEditorState === 'function';
+};
+
 setupNestedEditor(this, '__callToActionHtmlEditor', {
-    editor: dataset.callToActionHtmlEditor as LexicalEditor | undefined,
+    editor: isLexicalEditor(dataset.callToActionHtmlEditor) ? dataset.callToActionHtmlEditor : undefined,
     nodes: BASIC_NODES
 });
 setupNestedEditor(this, '__sponsorLabelHtmlEditor', {
-    editor: dataset.sponsorLabelHtmlEditor as LexicalEditor | undefined,
+    editor: isLexicalEditor(dataset.sponsorLabelHtmlEditor) ? dataset.sponsorLabelHtmlEditor : undefined,
     nodes: BASIC_NODES
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
setupNestedEditor(this, '__callToActionHtmlEditor', {editor: dataset.callToActionHtmlEditor as LexicalEditor | undefined, nodes: BASIC_NODES});
setupNestedEditor(this, '__sponsorLabelHtmlEditor', {editor: dataset.sponsorLabelHtmlEditor as LexicalEditor | undefined, nodes: BASIC_NODES});
const isLexicalEditor = (value: unknown): value is LexicalEditor => {
return typeof value === 'object' &&
value !== null &&
'getEditorState' in value &&
typeof (value as {getEditorState?: unknown}).getEditorState === 'function';
};
setupNestedEditor(this, '__callToActionHtmlEditor', {editor: isLexicalEditor(dataset.callToActionHtmlEditor) ? dataset.callToActionHtmlEditor : undefined, nodes: BASIC_NODES});
setupNestedEditor(this, '__sponsorLabelHtmlEditor', {editor: isLexicalEditor(dataset.sponsorLabelHtmlEditor) ? dataset.sponsorLabelHtmlEditor : undefined, nodes: BASIC_NODES});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/nodes/CallToActionNode.tsx` around lines 60 - 61,
The dataset values are being blindly cast to LexicalEditor and passed into
setupNestedEditor, causing runtime failures if the object isn't a real editor;
add a runtime type guard before calling setupNestedEditor for both
dataset.callToActionHtmlEditor and dataset.sponsorLabelHtmlEditor by checking
the presence/type of an editor API (e.g., verify the object is truthy and has a
getEditorState function) and only pass it as the editor when that guard passes,
otherwise pass undefined; update the calls that reference
__callToActionHtmlEditor and __sponsorLabelHtmlEditor so setupNestedEditor
receives a validated LexicalEditor or undefined (this prevents later
getEditorState() calls from throwing).

Comment on lines 99 to +100
decorate() {
const props = this as unknown as ProductNodeProperties;
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check BaseProductNode to see what properties are available
rg -n "class.*ProductNode" --type=ts -A 20

Repository: TryGhost/Koenig

Length of output: 1802


🏁 Script executed:

# Find and examine BaseProductNode
fd -type f -name "*.tsx" -o -name "*.ts" | xargs rg -l "class.*BaseProductNode" | head -5

Repository: TryGhost/Koenig

Length of output: 296


🏁 Script executed:

# Check the ProductNodeProperties interface and full context of the decorate method
cat -n packages/koenig-lexical/src/nodes/ProductNode.tsx | sed -n '1,120p'

Repository: TryGhost/Koenig

Length of output: 6189


🏁 Script executed:

# Search for BaseProductNode definition across the codebase
rg -n "class.*BaseProductNode|interface.*ProductNodeProperties" --type=ts -A 10

Repository: TryGhost/Koenig

Length of output: 1978


🏁 Script executed:

# Check if kg-default-nodes package is available in node_modules or dependencies
fd -type f -name "package.json" | xargs grep -l "kg-default-nodes"

Repository: TryGhost/Koenig

Length of output: 231


🏁 Script executed:

# Look for similar nodes that might have the same pattern
rg -n "as unknown as" packages/koenig-lexical/src/nodes/ --type=tsx

Repository: TryGhost/Koenig

Length of output: 86


🏁 Script executed:

# Check other nodes for how they handle property access
fd -type f -name "*.tsx" -path "*/nodes/*" | head -10

Repository: TryGhost/Koenig

Length of output: 229


🏁 Script executed:

# Check package.json for dependencies
cat -n packages/koenig-lexical/package.json | grep -A 5 -B 5 "kg-default-nodes"

Repository: TryGhost/Koenig

Length of output: 1315


🏁 Script executed:

# Search for similar type casting patterns
rg "as unknown as" packages/koenig-lexical/src/nodes/

Repository: TryGhost/Koenig

Length of output: 630


🏁 Script executed:

# Check other node files to see if they use similar patterns
ls -la packages/koenig-lexical/src/nodes/*.tsx | head -10

Repository: TryGhost/Koenig

Length of output: 1109


🏁 Script executed:

# Look at another node implementation for comparison
fd "Node.tsx$" packages/koenig-lexical/src/nodes/ | head -3 | xargs -I {} sh -c 'echo "=== {} ===" && head -120 {}'

Repository: TryGhost/Koenig

Length of output: 7643


🏁 Script executed:

# Check if there's a node_modules or dist directory with type definitions
fd "ProductNode" packages/koenig-lexical/node_modules/@tryghost/kg-default-nodes 2>/dev/null | head -5

Repository: TryGhost/Koenig

Length of output: 41


🏁 Script executed:

# Look at how AudioNode accesses properties directly without the interface cast
cat -n packages/koenig-lexical/src/nodes/AudioNode.tsx | sed -n '50,60p'

Repository: TryGhost/Koenig

Length of output: 643


🏁 Script executed:

# Check the exact properties accessed in ProductNode and compare with ButtonNode
cat -n packages/koenig-lexical/src/nodes/ButtonNode.tsx | sed -n '30,45p'

Repository: TryGhost/Koenig

Length of output: 687


Replace unsafe double-cast with direct property access.

The pattern this as unknown as ProductNodeProperties unnecessarily bypasses TypeScript's type checking. Other nodes in the codebase (e.g., AudioNode, ButtonNode) access inherited properties directly with individual type assertions, which is safer and more maintainable.

Replace line 100 with direct property access:

Suggested change
     decorate() {
-        const props = this as unknown as ProductNodeProperties;
         return (
             <KoenigCardWrapper nodeKey={this.getKey()}>
                 <ProductNodeComponent
-                    buttonText={props.productButton}
-                    buttonUrl={props.productUrl}
-                    description={props.productDescription}
+                    buttonText={this.productButton as string}
+                    buttonUrl={this.productUrl as string}
+                    description={this.productDescription as string}
                     descriptionEditor={this.__productDescriptionEditor}
                     descriptionEditorInitialState={this.__productDescriptionEditorInitialState as string | undefined}
-                    imgHeight={props.productImageHeight}
-                    imgSrc={props.productImageSrc}
-                    imgWidth={props.productImageWidth}
-                    isButtonEnabled={props.productButtonEnabled}
-                    isRatingEnabled={props.productRatingEnabled}
+                    imgHeight={this.productImageHeight as number}
+                    imgSrc={this.productImageSrc as string}
+                    imgWidth={this.productImageWidth as number}
+                    isButtonEnabled={this.productButtonEnabled as boolean}
+                    isRatingEnabled={this.productRatingEnabled as boolean}
                     nodeKey={this.getKey()}
-                    starRating={props.productStarRating}
-                    title={props.productTitle}
+                    starRating={this.productStarRating as number}
+                    title={this.productTitle as string}
                     titleEditor={this.__productTitleEditor}
                     titleEditorInitialState={this.__productTitleEditorInitialState as string | undefined}
                 />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/nodes/ProductNode.tsx` around lines 99 - 100, In
decorate() of ProductNode, remove the unsafe double-cast "this as unknown as
ProductNodeProperties" and instead access the node's inherited properties
directly using targeted assertions or native access (following the pattern used
in AudioNode/ButtonNode); update references inside decorate() to cast only where
necessary (e.g., cast this to the specific interface when accessing
ProductNodeProperties fields) so you avoid the broad unknown-to-type cast while
keeping ProductNode and ProductNodeProperties as the identifying symbols to
locate changes.

Comment on lines +132 to +150
const handleImageChange = async (files: File[] | FileList | null) => {
// reset original src so it can be replaced with preview and upload progress
editor.update(() => {
const node = $getNodeByKey(nodeKey);
const node = $getSignupNodeByKey(nodeKey);
if (!node) {return;}
node.backgroundImageSrc = '';
});

const {imageSrc} = await backgroundImageUploadHandler(files, imageUploader.upload);
const result = await backgroundImageUploadHandler(Array.from(files!), imageUploader.upload);
if (!result) {return;}
const {imageSrc} = result;

editor.update(() => {
const node = $getNodeByKey(nodeKey);
node.backgroundImageSrc = imageSrc;
const node = $getSignupNodeByKey(nodeKey);
if (!node) {return;}
node.backgroundImageSrc = imageSrc ?? "";
});

setLastBackgroundImage(imageSrc);
setLastBackgroundImage(imageSrc as string);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

cd packages/koenig-lexical && wc -l src/nodes/SignupNodeComponent.tsx

Repository: TryGhost/Koenig

Length of output: 96


🏁 Script executed:

cd packages/koenig-lexical && sed -n '120,160p' src/nodes/SignupNodeComponent.tsx

Repository: TryGhost/Koenig

Length of output: 1435


🏁 Script executed:

cd packages/koenig-lexical && rg -B 3 -A 10 "backgroundImageUploadHandler" src/

Repository: TryGhost/Koenig

Length of output: 7961


🏁 Script executed:

cd packages/koenig-lexical && rg "export.*backgroundImageUploadHandler|const backgroundImageUploadHandler|function backgroundImageUploadHandler" src/

Repository: TryGhost/Koenig

Length of output: 221


🏁 Script executed:

cd packages/koenig-lexical && sed -n '1,30p' src/utils/imageUploadHandler.ts

Repository: TryGhost/Koenig

Length of output: 1244


🏁 Script executed:

cd packages/koenig-lexical && rg -B 2 -A 8 "useFileDragAndDrop" src/

Repository: TryGhost/Koenig

Length of output: 18643


🏁 Script executed:

cd packages/koenig-lexical && sed -n '1,50p' src/hooks/useFileDragAndDrop.ts

Repository: TryGhost/Koenig

Length of output: 1602


Move file validation before mutating state.

handleImageChange accepts null (passed from onFileChange via e.target.files), but clears backgroundImageSrc before validating the input. If files is null, Array.from(null) throws. If the upload fails, the image remains cleared. Validate and return early on empty/null input before any state mutations.

Suggested fix
const handleImageChange = async (files: File[] | FileList | null) => {
+    const nextFiles = files ? Array.from(files) : [];
+    if (nextFiles.length === 0) {
+        return;
+    }
+
     // reset original src so it can be replaced with preview and upload progress
     editor.update(() => {
         const node = $getSignupNodeByKey(nodeKey);
         if (!node) {return;}
         node.backgroundImageSrc = '';
     });

-    const result = await backgroundImageUploadHandler(Array.from(files!), imageUploader.upload);
+    const result = await backgroundImageUploadHandler(nextFiles, imageUploader.upload);
     if (!result) {return;}
     const {imageSrc} = result;

     editor.update(() => {
         const node = $getSignupNodeByKey(nodeKey);
         if (!node) {return;}
         node.backgroundImageSrc = imageSrc ?? "";
     });

-    setLastBackgroundImage(imageSrc as string);
+    if (imageSrc) {
+        setLastBackgroundImage(imageSrc);
+    }
     setImageRemoved(false);
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/nodes/SignupNodeComponent.tsx` around lines 132 -
150, In handleImageChange, validate the incoming files and return early if files
is null/empty before calling editor.update or Array.from; move the null/empty
check ahead of the initial editor.update so you don't clear
node.backgroundImageSrc when there's nothing to upload, and also avoid calling
Array.from(files) on null. After validating, call
backgroundImageUploadHandler(Array.from(files), imageUploader.upload), then on
successful result run editor.update to set node.backgroundImageSrc and call
setLastBackgroundImage; keep references to $getSignupNodeByKey,
backgroundImageUploadHandler, editor.update, imageUploader.upload, and
setLastBackgroundImage when making the change.

Comment on lines 74 to +88
if (event.key === ':') {
if (cursorInInlineCodeBlock() === true) {
return false;
}
const emojis = await SearchIndex.search(queryString);
if (emojis.length === 0) {
return;
}
const emojiMatch = emojis?.[0].id === queryString; // only look for exact match
if (emojiMatch) {
handleCompletionInsertion(emojis[0]);
event.preventDefault();
return true;
}
SearchIndex.search(queryString).then((emojis: Emoji[]) => {
if (emojis.length === 0) {
return;
}
const emojiMatch = emojis[0].id === queryString;
if (emojiMatch) {
handleCompletionInsertion(emojis[0]);
}
});
event.preventDefault();
return true;
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Only swallow : after an exact shortcode match is confirmed.

This now prevents the closing colon for any active emoji query, even when there is no exact emoji id. Typing something like :notanemoji: will drop the final : instead of letting the text through.

💡 Suggested fix
                     if (event.key === ':') {
                         if (cursorInInlineCodeBlock() === true) {
                             return false;
                         }
-                        SearchIndex.search(queryString).then((emojis: Emoji[]) => {
-                            if (emojis.length === 0) {
-                                return;
-                            }
-                            const emojiMatch = emojis[0].id === queryString;
-                            if (emojiMatch) {
-                                handleCompletionInsertion(emojis[0]);
-                            }
-                        });
-                        event.preventDefault();
-                        return true;
+                        const emojiMatch = searchResults?.find(emoji => emoji.id === queryString);
+                        if (!emojiMatch) {
+                            return false;
+                        }
+                        event.preventDefault();
+                        handleCompletionInsertion(emojiMatch);
+                        return true;
                     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/koenig-lexical/src/plugins/EmojiPickerPlugin.tsx` around lines 74 -
88, The key handler currently prevents default for ':' unconditionally; change
it so SearchIndex.search(queryString) is awaited (or use its promise) and only
when an exact shortcode match is found (compare emojis[0].id === queryString)
call handleCompletionInsertion(emojis[0]) and then call event.preventDefault()
and return true; otherwise do not preventDefault and return false so the
trailing ':' is inserted normally. Keep the early cursorInInlineCodeBlock()
check as-is and update the branches around SearchIndex.search,
handleCompletionInsertion, and event.preventDefault to only run on confirmed
exact match.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant