Engineering Journal
Pdf Processor
Pdf Processor

Three Editable Surfaces, One Source of Truth: Retrofitting the Controlled-Input Pattern

2026-05-30

TLDR: The PDF processor had three editable surfaces showing the same HTML: the HTML tab preview, the visual diff right pane, and the Monaco code editor. Each had a different lifecycle. Each silently overwrote the others. The fix is the controlled-input pattern applied to multiple DOM surfaces: a shared _syncing guard flag, a single applyHtmlEverywhere coordinator, and a skip-source rule so the user's caret survives. This is part 3 of a series on silent math bugs in our PDF editor.

Repo: tools/pdf-processor

The mess

The PDF processor has three editable surfaces that all show the extracted HTML:

  1. #html-preview in the HTML tab. contenteditable="true".
  2. #visual-diff-html in the Visual Diff right pane. Also contenteditable="true".
  3. The Monaco editor. Editable through Monaco's own model.
In a clean architecture, all three would be views of one canonical state. They were not.

Reading the original code, I found three completely different lifecycles:

Each surface believed it was the source of truth, and they disagreed.

The fix

The controlled-input pattern, applied to a multi-surface DOM. A single coordinator. A single guard flag. Every change handler routes through the coordinator. Every change handler returns early if the coordinator is mid-write.

// htmlSync.js
import { state } from '../state.js';
import { initTableFeatures } from '../utils/tableLogic.js';

let _syncing = false; const _debouncers = new WeakMap();

const SURFACE_IDS = ['html-preview', 'visual-diff-html']; const DEBOUNCE_MS = 200;

export function isSyncing() { return _syncing; }

export function initHTMLSync() { SURFACE_IDS.forEach(wirePreview); }

function wirePreview(id) { const el = document.getElementById(id); if (!el) return; el.addEventListener('input', () => { if (_syncing) return; const prev = _debouncers.get(el); if (prev) clearTimeout(prev); const t = setTimeout(() => applyHtmlEverywhere(el.innerHTML, el), DEBOUNCE_MS); _debouncers.set(el, t); }); }

export function applyHtmlEverywhere(html, skipEl = null) { if (_syncing) return; _syncing = true; try { state.pdf1.extractedHTML = html; const clean = sanitize(html);

for (const id of SURFACE_IDS) { const el = document.getElementById(id); if (!el || el === skipEl) continue; if (el.innerHTML !== clean) { el.innerHTML = clean; initTableFeatures(el); } }

const editor = state.monacoEditor; if (editor && editor.getValue() !== html) { editor.getModel()?.setValue(html); } } finally { _syncing = false; } }

function sanitize(html) { return typeof window.DOMPurify !== 'undefined' ? window.DOMPurify.sanitize(html, { ADD_TAGS: ['img'], ALLOW_DATA_ATTR: true }) : html; }

Three things in this module deserve attention.

The shared flag, exported

Monaco's onDidChangeModelContent lives in another module. It needs to skip the synchronous re-fire that happens when applyHtmlEverywhere itself calls model.setValue(). Rather than pass the flag through props, we export isSyncing() and let any change handler in the codebase ask whether a write is in progress:

// monacoSetup.js
editor.onDidChangeModelContent(() => {
  if (isSyncing()) return;
  applyHtmlEverywhere(editor.getValue(), null);
});

The skip-source pattern

When the user is typing in #html-preview, we want their edits to flow to Monaco and to #visual-diff-html, but we do not want to overwrite #html-preview's own innerHTML on every keystroke. That would obliterate their caret.

So applyHtmlEverywhere takes a skipEl argument. The source surface gets skipped on cross-write. Its own natural typing keeps it correct.

Asymmetric sanitization

DOMPurify sanitizes the HTML before mirroring it across surfaces. But the surface the user is typing into is left raw.

Sanitizing on every keystroke would strip half-typed tags and re-create the DOM on every input event, which destroys the cursor. We accept some risk on the typed-in surface because the alternative is unusable.

Debounce, briefly

The debounce on the input listeners (200ms) is there for performance. The user can type freely; the cross-write only fires when they pause briefly. Monaco's setValue is heavyweight enough that doing it on every keystroke would tear the experience.

The result

After this module landed, the visual diff pane stopped clobbering edits on tab switch. The download button now picks up edits made anywhere. The Monaco editor and the rendered preview agree at all times.


Next in the series: The pattern under all three. The bugs in parts 1, 2, and 3 were the same bug in different clothes.

Read this post in the full Engineering Journal →