WYSIWYG - unless you use tokens!

a month ago

Illustration

The Context

SeekOut''s email editor had a problem that made grown developers weep. We were using RoosterJS to build a sophisticated email composer where recruiters could insert dynamic variables like {{First_Name}} and {{Company}} into their outreach messages. Think mail merge, but for talent acquisition at scale.

The concept was simple: recruiters type their email template, drop in some variables, and the system automatically personalizes each message for thousands of candidates. In practice, it was a performance nightmare that made typing feel like molasses and caused the editor to freeze when handling realistic content volumes.

Every keystroke triggered a cascade of DOM manipulations. Paste a message template with multiple variables? The editor would lock up for seconds. Try to apply bold formatting to text containing variables? The cursor would jump around like a caffeinated squirrel.

The Problem

Our RoosterJS implementation was suffering from what I like to call "event storm syndrome":

Performance death spiral: Every input event triggered editor.getContent(), which internally fired ExtractContentWithDOM, converting frontend spans to backend tokens, then back to spans. This created recursive event loops that made typing feel like you were connected to dial-up internet.

Content thrashing: Each variable detected caused a separate setContent() call, resetting the entire editor DOM. Paste a template with 10 variables? That''s 10 full DOM resets before you could blink.

Cursor chaos: Because we were constantly resetting content, the cursor position became unreliable. Users would type at position 50, and their text would appear at position 23.

The breaking point came when our largest customer tried to paste their standard outreach template—a modest 500-word message with 8 variables. The editor froze for 15 seconds, then corrupted half the variables.

The Solution

Debounced Traversal Strategy

Instead of processing variables on every keystroke, batch the operations and only traverse when the user pauses typing.

private debouncedTraverseBodyFn = debounce(
  this.traverseBody.bind(this),
  DynamicTokenConstants.TRAVERSE_DELAY
);

// On input events, schedule traversal instead of executing immediately
onPluginEvent(event: PluginEvent) {
  switch (event.eventType) {
    case PluginEventType.Input:
      this.debouncedTraverseBody();
      break;
  }
}

Intelligent Content Detection

Only process content when it''s actually necessary, and avoid the recursive getContent() → setContent() cycle.

The key insight was distinguishing between user input (which should use traversal) and paste operations (which need immediate processing).

In-Place Node Manipulation

Instead of resetting entire content, manipulate individual text nodes and wrap them with entity spans.

// Split the text node at the exact position
const startSplit = splitTextNode(containerNode as Text, index, false);
const endSplit = splitTextNode(startSplit, match.length, true);

// Insert entity without resetting content
insertEntity(this.editor, DynamicTokenConstants.ENTITY_TYPE, endSplit, ...);

Text node splitting is surgical—you need to split at the exact character boundaries of the variable match, then wrap only the matched portion.

Entity State Management

Track variable validity states and handle transitions between valid/invalid without destroying user formatting.

Variables can become invalid through user editing—someone might delete a closing bracket, turning {{First_Name}} into {{First_Name}. The system needs to detect this and remove the entity styling while preserving the text.

The Impact

Performance transformation: Editor response time dropped from 15+ seconds to under 200ms for large templates. The recursive event loops were eliminated entirely.

Cursor stability: Fixed the jumping cursor issue that was driving users crazy. Typing now feels natural and predictable, even with multiple variables.

Memory efficiency: Eliminated the MutationObserver memory leaks by switching to event-driven state management. Long editing sessions no longer degrade performance.

Variable reliability: Fixed the undefined variable resolution bug that was corrupting customer templates. Variables now maintain their integrity through all editing operations.

Rich formatting support: Users can now apply bold, italic, and underline formatting to variables without breaking the template system. Formatting persists through save/export cycles.

The most satisfying outcome was cultural: QA stopped filing "editor is broken" tickets. When your most complex feature becomes your most reliable, you know you''ve solved the right problems.

What we learned is that rich text editors are deceptively complex beasts. The DOM is stateful, users expect immediate feedback, and performance matters more than features.

Sometimes the best way to make software faster is to make it do less. But you have to be very careful about what you choose not to do.