Skip to content

bug: Input Focus Loss in Custom NodeView Components During Typing #507

@smartitservices98

Description

@smartitservices98

Provide environment information

System:
OS: Windows 11 10.0.26100
CPU: (12) x64 13th Gen Intel(R) Core(TM) i5-13420H
Memory: 6.36 GB / 15.71 GB
Binaries:
Node: 22.15.0 - C:\Program Files\nodejs\node.EXE
npm: 10.9.2 - C:\Program Files\nodejs\npm.CMD
pnpm: 10.12.3 - ~\AppData\Roaming\npm\pnpm.CMD

Novel Editor Version: "novel": "^1.0.2"
React Version: "react": "^18.3.1"
Framework: React with TypeScript, Shadcn ui and Tailwind Css
Use Case: Form builder with custom NodeView components

Describe the bug

Summary

Custom NodeView components lose input focus after each keystroke, requiring users to click back into the input field for every character typed. This makes form building completely unusable for custom form elements.

Problem Description

When typing in input fields within custom NodeView components (React components), the input loses focus after each keystroke. Users can only type one character at a time before having to click back into the input field.

Affected Components

  • Custom NodeView components with input fields (e.g., form elements like ShortAnswer, MultipleChoice, etc.)
  • Built-in editor elements (headings, paragraphs) work perfectly fine

Steps to Reproduce

  1. Create a custom NodeView component with an input field:
export const ShortAnswerComponent = ({ node, updateAttributes, editor }: NodeViewProps) => {
  const [localLabel, setLocalLabel] = useState(node.attrs.label || "");
  
  return (
    <NodeViewWrapper>
      <Input
        value={localLabel}
        onChange={(e) => {
          setLocalLabel(e.target.value);
          updateAttributes({ label: e.target.value });
        }}
        placeholder="Type a question"
      />
    </NodeViewWrapper>
  );
};
  1. Register the extension and use it in the editor
  2. Try typing continuously in the input field
  3. Observe that focus is lost after each character

Expected Behavior

Users should be able to type continuously in custom NodeView input fields without losing focus.

Actual Behavior

Input focus is lost after each keystroke, requiring manual refocusing for each character.

Investigation Findings

Through extensive debugging, we identified the root cause:

Primary Issue: Editor Recreation

The editor instance is being recreated on every content change, which destroys and rebuilds all NodeView components, causing focus loss.

Evidence from logs:

🎉 Editor onCreate triggered (multiple times per keystroke)
🏗️ Editor component rendering
🏗️ Initial value changed: true
🔧 Editor ref changed: null -> Editor instance

Contributing Factors:

  1. Changing initialValue prop: When the editor's initialValue prop changes, it triggers a complete recreation
  2. Unstable onChange callbacks: Parent component recreating the onChange function on every render
  3. NodeView re-rendering: Custom components re-rendering excessively due to attribute updates

Technical Analysis

Root Cause Chain:

  1. User types in NodeView input → updateAttributes() called
  2. Editor content changes → onUpdate callback triggered
  3. Parent component updates state → Editor props change
  4. Editor receives new initialValue or onChangeEditor recreates
  5. All NodeViews destroyed and rebuilt → Focus lost

Key Logs Pattern:

ShortAnswerComponent.tsx:20 🎯 ShortAnswerComponent rendering (4+ times per keystroke)
Editor.tsx:84 🎉 Editor onCreate triggered (editor recreation)
Editor.tsx:41 🏗️ Initial value changed: true (prop change detected)
FormBuilder.tsx:91 🔧 Editor ref changed: null (editor unmounted)

Attempted Solutions

We tried multiple approaches:

❌ Failed Approaches:

  • Debouncing updateAttributes calls
  • Using React.memo on NodeView components
  • Focus management with refs and cursor position tracking
  • Preventing content state updates during typing

✅ Partial Success:

  • Stabilizing the initialValue prop (preventing it from changing)
  • Memoizing the onChange callback with stable dependencies
  • Conditional editor rendering to prevent null→content transitions

Workaround Implementation

The most effective workaround involves:

  1. Stable Initial Value Management:
const [stableInitialValue, setStableInitialValue] = useState(null);
const hasSetStableInitialValue = useRef(false);

// Set once, never change
useEffect(() => {
  if (!hasSetStableInitialValue.current) {
    setStableInitialValue(defaultContent);
    hasSetStableInitialValue.current = true;
  }
}, []);
  1. Stable onChange Callback:
const handleContentChange = useCallback((jsonContent, htmlContent) => {
  // Store in ref instead of state to prevent re-renders
  latestContentRef.current = jsonContent;
  setHtmlContent(htmlContent);
}, []); // Empty dependencies
  1. Conditional Editor Rendering:
{stableInitialValue ? (
  <Editor
    key="stable-editor"
    initialValue={stableInitialValue}
    onChange={handleContentChange}
  />
) : (
  <LoadingSpinner />
)}

Suggested Fix

The issue seems to stem from how Novel handles prop changes. Consider:

  1. Internal State Management: Editor should maintain its own internal state and not recreate when initialValue changes after initialization
  2. Stable NodeView Lifecycle: Ensure NodeView components aren't destroyed/recreated during normal content updates
  3. Prop Change Optimization: Differentiate between "initial setup" and "content updates" to avoid unnecessary recreation

Impact

This issue makes Novel editor unusable for any form builder or application requiring custom NodeView components with user input, which is a common use case for rich text editors.

Link to reproduction

https://github.com/Ghufran496/MetanoaEditor

To reproduce

Steps to Reproduce

  1. Create a custom NodeView component with an input field:
export const ShortAnswerComponent = ({ node, updateAttributes, editor }: NodeViewProps) => {
  const [localLabel, setLocalLabel] = useState(node.attrs.label || "");
  
  return (
    <NodeViewWrapper>
      <Input
        value={localLabel}
        onChange={(e) => {
          setLocalLabel(e.target.value);
          updateAttributes({ label: e.target.value });
        }}
        placeholder="Type a question"
      />
    </NodeViewWrapper>
  );
};
  1. Register the extension and use it in the editor
  2. Try typing continuously in the input field
  3. Observe that focus is lost after each character

Additional information

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions