- Uses
beforeinputevent (fires BEFORE DOM mutations) - Two-tier controller system: Level2InputController (modern browsers) vs Level0InputController (fallback)
- Still has Safari issues - same problems we're experiencing (text appearing in reverse, rendering delays)
- Uses "updating" flag pattern to prevent infinite loops
- Separates read and write phases using
requestAnimationFrame - Document-diffing algorithm for minimal DOM updates
From ProseMirror's CodeMirror integration example:
class CodeBlock {
constructor() {
this.updating = false // ← Synchronization guard
}
// When inner editor changes
forwardUpdate() {
if (this.updating || !this.cm.hasFocus) return // ← Skip if we're updating
// ... forward changes to outer editor
}
// When outer editor changes
update(node) {
this.updating = true // ← Set flag
this.cm.dispatch({...}) // ← Update inner editor
this.updating = false // ← Clear flag
}
}Key Insight: Changes flow in only ONE direction at a time.
Current broken flow:
User types → input event → handleInput()
↓
rawText = textarea.value (read)
↓
Svelte sees rawText changed
↓
bind:value triggers
↓
textarea.value = rawText (WRITE BACK - causes race!)
↓
Next keystroke confused
↓
Character corruption
// Add flag to prevent feedback loop
let isUpdatingFromInput = $state(false);
function handleInput() {
if (!textareaElement) return;
// Set flag to prevent bind:value from writing back
isUpdatingFromInput = true;
try {
rawText = textareaElement.value;
doc.updateRawText(rawText);
updateCursorPosition();
scheduleEvaluation();
} finally {
// Clear flag AFTER Svelte's reactivity has processed
queueMicrotask(() => {
isUpdatingFromInput = false;
});
}
}<!-- Modified textarea binding -->
<textarea bind:this={textareaElement} bind:value={rawText} oninput={handleInput} ... />But wait - Svelte's bind:value is automatic. We can't directly prevent it from updating.
let internalText = $state('');
let isUpdatingFromInput = $state(false);
function handleInput() {
if (!textareaElement) return;
isUpdatingFromInput = true;
internalText = textareaElement.value;
doc.updateRawText(internalText);
updateCursorPosition();
scheduleEvaluation();
// Use microtask to ensure Svelte's reactivity completes
queueMicrotask(() => {
isUpdatingFromInput = false;
});
}
// Sync internal state to textarea, but ONLY when not coming from input
$effect(() => {
if (textareaElement && !isUpdatingFromInput) {
// Only update textarea when changes come from outside (e.g., evaluation results)
textareaElement.value = internalText;
}
});<!-- NO bind:value - manual control -->
<textarea bind:this={textareaElement} oninput={handleInput} ... />CodeMirror separates read/write phases using requestAnimationFrame. We can apply similar logic:
let isTyping = $state(false);
let typingTimer: any = null;
function handleInput() {
if (!textareaElement) return;
// Mark as typing
isTyping = true;
clearTimeout(typingTimer);
// Update from textarea (one-way)
rawText = textareaElement.value;
doc.updateRawText(rawText);
updateCursorPosition();
// Schedule evaluation AFTER typing stops
typingTimer = setTimeout(() => {
isTyping = false;
evaluateDocument();
}, USER_INPUT_DEBOUNCE_MS);
}
// Only allow bind:value updates when NOT typing
$effect(() => {
if (textareaElement && !isTyping && rawText !== textareaElement.value) {
// Evaluation results can update textarea, but only when user not typing
textareaElement.value = rawText;
}
});Based on research, combine both techniques:
- Use "updating" flag (from ProseMirror pattern)
- Use
beforeinputevent (from Trix pattern) - Remove
bind:valueand manage manually - Separate evaluation from input handling
// Synchronization flags
let isUpdatingFromUser = $state(false);
let isUpdatingFromEvaluation = $state(false);
// Handle user input
function handleInput(event: Event) {
if (!textareaElement || isUpdatingFromEvaluation) return;
isUpdatingFromUser = true;
try {
// Read from textarea (source of truth)
const newText = textareaElement.value;
// Update document model
doc.updateRawText(newText);
// Update cursor immediately
updateCursorPosition();
// Schedule evaluation
scheduleEvaluation();
} finally {
// Clear flag after current event loop
queueMicrotask(() => {
isUpdatingFromUser = false;
});
}
}
// Handle evaluation results
async function evaluateDocument() {
if (isUpdatingFromUser) {
// Skip evaluation if user is actively typing
return;
}
isUpdatingFromEvaluation = true;
try {
// ... evaluation logic ...
// Update overlay rendering (NOT textarea)
lines = doc.getLines();
// Restore cursor position
updateCursorPosition();
} finally {
isUpdatingFromEvaluation = false;
}
}
// Optional: Use beforeinput for better control (Level 2 browsers)
function handleBeforeInput(event: InputEvent) {
// Can intercept and modify input before it affects DOM
// Useful for special handling (auto-formatting, etc.)
}<textarea
bind:this={textareaElement}
oninput={handleInput}
onbeforeinput={handleBeforeInput} <!-- Optional -->
value={initialText} <!-- Only for SSR -->
/>- Never write to textarea during input event processing
- Use flags to prevent circular updates
- Separate user input from programmatic updates
- Use microtasks/requestAnimationFrame for timing control
- Evaluation should NEVER modify textarea - only overlay
After implementing, test:
- Rapid typing with random delays
- Typing during evaluation
- Safari/WebKit specific tests
- Character order preservation
- Cursor position accuracy
✅ Prevents race conditions - Flags ensure one-way data flow ✅ Safari compatible - Same patterns used by Trix/ProseMirror ✅ Maintains textarea as source of truth - No bind:value interference ✅ Clean separation - User input vs evaluation results ✅ Testable - Flags can be inspected in tests
- Implement updating flags
- Remove bind:value
- Add manual synchronization with $effect
- Test in WebKit
- Verify no character corruption