Scenario

Framework state synchronization issues with contenteditable

When using contenteditable with JavaScript frameworks like Vue, Angular, or Svelte, state synchronization between the DOM and framework state can cause caret position issues, event mismatches, and performance problems. Each framework has unique challenges when integrating with contenteditable.

other
Scenario ID
scenario-framework-state-sync

Details

When using contenteditable with JavaScript frameworks like Vue, Angular, or Svelte, state synchronization between the DOM and framework state can cause caret position issues, event mismatches, and performance problems.

Observed Behavior

Vue-specific Issues

  • Caret jumps on re-render: Similar to React, Vue’s reactivity can cause caret to reset
  • v-model doesn’t work: v-model is designed for form inputs, not contenteditable
  • Event timing: change events don’t fire reliably, requiring input events
  • Re-render frequency: Every keystroke can trigger watchers and re-renders

Angular-specific Issues

  • No native form control: contenteditable doesn’t implement ControlValueAccessor by default
  • Model binding: ngModel doesn’t work directly with contenteditable
  • Event handling: Firefox-specific issues with input events
  • Change detection: Zone.js can cause performance issues with frequent updates

Svelte-specific Issues

  • Reactive updates: Svelte’s reactivity can cause similar caret jumping
  • DOM synchronization: Updates may not sync correctly with DOM state

Browser Comparison

  • Safari: Most affected by framework re-renders
  • Firefox: Issues with event handling and caret positioning
  • Chrome: Generally better but still affected
  • Edge: Similar to Chrome

Impact

  • Poor user experience: Caret jumping disrupts typing
  • Framework limitations: Makes framework integration challenging
  • Performance overhead: Frequent re-renders cause lag
  • Development complexity: Requires custom solutions for each framework

Workarounds

Vue Solutions

1. Custom Component with Caret Preservation

<template>
  <div
    ref="editable"
    contenteditable="true"
    @input="onInput"
    @blur="onBlur"
  ></div>
</template>

<script>
export default {
  props: {
    value: String
  },
  emits: ['update:value'],
  data() {
    return {
      caretOffset: 0
    }
  },
  methods: {
    onInput(e) {
      const el = this.$refs.editable
      const sel = window.getSelection()
      if (sel.rangeCount > 0) {
        const range = sel.getRangeAt(0)
        const pre = range.cloneRange()
        pre.selectNodeContents(el)
        pre.setEnd(range.startContainer, range.startOffset)
        this.caretOffset = pre.toString().length
      }
      this.$emit('update:value', el.innerText)
    },
    restoreCaret() {
      // Restore caret position logic
      const el = this.$refs.editable
      // Implementation similar to React example
    }
  },
  watch: {
    value(newVal) {
      const el = this.$refs.editable
      if (el.innerText !== newVal) {
        el.innerText = newVal
        this.$nextTick(this.restoreCaret)
      }
    }
  }
}
</script>

2. Debounce Updates

import { debounce } from 'lodash'

export default {
  methods: {
    onInput: debounce(function(e) {
      this.$emit('update:value', e.currentTarget.innerText)
    }, 300)
  }
}

Angular Solutions

1. Custom ControlValueAccessor

import { Component, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

@Component({
  selector: 'app-contenteditable',
  template: '<div contenteditable="true" (input)="onInput($event)"></div>',
  providers: [{
    provide: NG_VALUE_ACCESSOR,
    useExisting: forwardRef(() => ContentEditableComponent),
    multi: true
  }]
})
export class ContentEditableComponent implements ControlValueAccessor {
  private caretPosition: number = 0;
  
  onInput(event: Event) {
    const element = event.target as HTMLElement;
    this.saveCaretPosition(element);
    this.onChange(element.innerText);
  }
  
  writeValue(value: string): void {
    const element = document.querySelector('[contenteditable]') as HTMLElement;
    if (element && element.innerText !== value) {
      element.innerText = value;
      this.restoreCaretPosition(element);
    }
  }
  
  registerOnChange(fn: (value: string) => void): void {
    this.onChange = fn;
  }
  
  registerOnTouched(fn: () => void): void {
    this.onTouched = fn;
  }
  
  private onChange = (value: string) => {};
  private onTouched = () => {};
  
  private saveCaretPosition(element: HTMLElement): void {
    const selection = window.getSelection();
    if (selection && selection.rangeCount > 0) {
      const range = selection.getRangeAt(0);
      const preRange = range.cloneRange();
      preRange.selectNodeContents(element);
      preRange.setEnd(range.startContainer, range.startOffset);
      this.caretPosition = preRange.toString().length;
    }
  }
  
  private restoreCaretPosition(element: HTMLElement): void {
    // Restore logic
  }
}

Svelte Solutions

<script>
  let content = '';
  let editableElement;
  let caretPosition = 0;
  
  function saveCaretPosition() {
    const selection = window.getSelection();
    if (selection && selection.rangeCount > 0) {
      const range = selection.getRangeAt(0);
      const preRange = range.cloneRange();
      preRange.selectNodeContents(editableElement);
      preRange.setEnd(range.startContainer, range.startOffset);
      caretPosition = preRange.toString().length;
    }
  }
  
  function handleInput(event) {
    saveCaretPosition();
    content = event.currentTarget.innerText;
  }
  
  $: if (content !== editableElement?.innerText) {
    editableElement.innerText = content;
    // Restore caret
  }
</script>

<div
  bind:this={editableElement}
  contenteditable="true"
  on:input={handleInput}
>{content}</div>

References

Scenario flow

Visual view of how this scenario connects to its concrete cases and environments. Nodes can be dragged and clicked.

React Flow mini map

Variants

Each row is a concrete case for this scenario, with a dedicated document and playground.

Case OS Device Browser Keyboard Status
ce-0560-framework-state-sync-vue Any Any Desktop or Laptop Any Safari Latest US draft

Cases

Open a case to see the detailed description and its dedicated playground.

Related Scenarios

Other scenarios that share similar tags or category.

Tags: framework, caret

Caret position jumps to beginning on React re-render

When using contentEditable elements in React, the caret (cursor) position jumps to the beginning of the element whenever the component re-renders. This occurs because React's reconciliation process replaces DOM nodes, causing the browser to lose track of the caret position. This issue is more prevalent in Safari and Firefox.

1 case
Tags: caret

Dark mode causes caret visibility and styling issues

When browser dark mode is enabled, contenteditable elements may experience invisible or poorly visible caret, inline style injection conflicts, background color issues, and form control styling problems. These issues are caused by missing color-scheme declarations and conflicts between browser-injected styles and custom CSS.

1 case
Tags: caret

Browser zoom causes caret and selection positioning issues

When the browser is zoomed (or content is scaled via CSS transforms), caret position and text selection in contenteditable elements can become inaccurate. Clicking at a certain position places the caret elsewhere, and selection highlights may not match the visual selection.

1 case
Tags: caret

Text caret is invisible on position:relative elements

When editing content inside an element with `position:relative`, the text caret (cursor) is completely invisible. Text can be typed and appears in the editor, but there's no visual feedback of where the insertion point is located.

2 cases

Comments & Discussion

Have questions, suggestions, or want to share your experience? Join the discussion below.