From b6ca816b6584ea5c2b723866b0a2579e795e1678 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BCdiger=20Rolf?= Date: Fri, 16 Oct 2026 23:37:12 +0200 Subject: [PATCH] fix(ui): RichTextEditor ignores stale echoes of its own edits (0.9.1) The outside-value sync called setContent whenever `value` differed from the editor's HTML. Own edits come back as `value` one render later; under load a keystroke lands before the sync effect runs, so the echo is already stale and reset the editor - dropping the newest keystroke and, for good, a trailing space. The editor now records the HTML it emits via onChange: an echo of a pending own emission is ignored, catching up with the newest emission clears the queue, and any other change of the prop is external and applied (also when it equals an earlier text, e.g. a form reset to ""). setContent still uses emitUpdate:false and addToHistory:false. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 19 +++++++++ packages/ui/package-lock.json | 4 +- packages/ui/package.json | 2 +- packages/ui/src/RichTextEditor.tsx | 64 +++++++++++++++++++++++++----- 4 files changed, 75 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37ccfef..516b350 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ Changelog ist die Upgrade-Anleitung für die Tools. ## [Unreleased] +### @basicbar/ui (→ wird `ui/v0.9.1`) + +**`RichTextEditor` verwirft keine Eingaben mehr durch veraltete Echos.** Der +Editor glich seinen `value` per Effekt ab und rief bei jeder Abweichung vom +aktuellen Editor-HTML `setContent` auf. Die eigenen Änderungen kommen aber +einen Render später als `value` zurück — unter Last (asynchrone +State-/Autosave-Runden) tippt man dazwischen weiter, das Echo ist dann schon +veraltet und setzte den Editor auf den älteren Text zurück: das neueste +Zeichen ging verloren, ein Leerzeichen am Ende endgültig (ProseMirror +entfernt es beim Parsen). Der Editor merkt sich jetzt seine eigenen +`onChange`-Werte und ignoriert deren Echos (aktuell wie veraltet). Echte +Änderungen von außen werden weiter übernommen — auch eine, die einem früheren +Wert gleicht (z. B. Formular-Reset auf `""`, Wechsel zu einer Frage mit +identischem Text). + +Migration: Tarball auf `ui/v0.9.1`, Vite-Dep-Cache leeren. Eigene +Echo-Wrapper um den Editor (abstimmbar: `RichTextInput` in +`TranslatableField.tsx`) können entfallen. + ### Template **App-spezifische Cookie-Namen für Session und CSRF** (#22): Browser trennen diff --git a/packages/ui/package-lock.json b/packages/ui/package-lock.json index 081afff..1f80fee 100644 --- a/packages/ui/package-lock.json +++ b/packages/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "@basicbar/ui", - "version": "0.9.0", + "version": "0.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@basicbar/ui", - "version": "0.9.0", + "version": "0.9.1", "license": "Apache-2.0", "dependencies": { "@tiptap/extension-bold": "^3.27.2", diff --git a/packages/ui/package.json b/packages/ui/package.json index 9b65461..f0cb7bd 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@basicbar/ui", - "version": "0.9.0", + "version": "0.9.1", "description": "Design-System-Basis der virtUOS -bar-Tools: Tailwind-Preset, Basis-Styles, Theme (Dark Mode), i18n-Bootstrap, contentLang, TranslatableField, Preferences, RichText; RichTextEditor (TipTap) als eigener Entry", "license": "Apache-2.0", "author": "Universität Osnabrück (virtUOS)", diff --git a/packages/ui/src/RichTextEditor.tsx b/packages/ui/src/RichTextEditor.tsx index 1775c7c..aaf2685 100644 --- a/packages/ui/src/RichTextEditor.tsx +++ b/packages/ui/src/RichTextEditor.tsx @@ -27,7 +27,7 @@ import { List, ListOrdered, } from "lucide-react"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; /** Bold that only recognises explicit / tags. TipTap's default @@ -118,6 +118,21 @@ export function RichTextEditor({ }: RichTextEditorProps) { const { t } = useTranslation(); + // Echo bookkeeping for the outside-value sync below. Our own edits come + // back as `value` one render later — and under load the user may type + // again before the sync effect runs, so that echo is already stale. We + // remember what we emitted to tell such echoes apart from genuine outside + // changes. + const sync = useRef({ + // The `value` prop the sync effect saw last. + prop: value, + // Newest HTML emitted via onChange (null: none since the last outside + // change), and every emission the parent hasn't caught up with yet (any + // of them may still come back as `value`). + latest: null as string | null, + pending: new Set(), + }).current; + async function insertImageFile(editor: Editor, file: File, pos?: number) { if (!onUploadImage || !editor.isEditable) return; let url: string; @@ -184,7 +199,12 @@ export function RichTextEditor({ Image, ], content: value, - onUpdate: ({ editor }) => onChange(editor.getHTML()), + onUpdate: ({ editor }) => { + const html = editor.getHTML(); + sync.latest = html; + sync.pending.add(html); + onChange(html); + }, editorProps: { attributes: { ...(id ? { id } : {}), @@ -251,18 +271,40 @@ export function RichTextEditor({ }); // Sync when the value changes from OUTSIDE (e.g. applying an AI rephrasing, - // or switching the active language tab). The guard avoids clobbering the - // cursor during normal typing (there the stored value already equals the - // editor's HTML). emitUpdate:false so this settles without a second round - // that would reset the caret to the top while the user is typing (#50). - // addToHistory:false keeps this out of the undo stack — otherwise a single - // Ctrl+Z right after an external sync (e.g. switching language tabs) would - // undo INTO the previous tab's content instead of the user's own last edit. + // switching the active language tab, a form reset). Echoes of our own edits + // are ignored — the current one and stale ones alike: under load a + // keystroke can land between the render carrying an echo and this effect, + // and resetting to that older text would drop the newest keystroke (and + // for good a trailing space, which ProseMirror strips on parse). A change + // of the prop that is not a pending own emission is external and applied, + // even when it equals an earlier text (e.g. resetting a field to ""). + // emitUpdate:false so this settles without a second round that would reset + // the caret to the top while the user is typing (#50). addToHistory:false + // keeps this out of the undo stack — otherwise a single Ctrl+Z right after + // an external sync (e.g. switching language tabs) would undo INTO the + // previous tab's content instead of the user's own last edit. useEffect(() => { - if (editor && value !== editor.getHTML()) { + if (!editor) return; + if (value === sync.prop) { + // Prop unchanged (e.g. the editor instance just became available): + // align only if the user hasn't edited yet. + if (sync.latest !== null) return; + } else { + sync.prop = value; + if (value === sync.latest) { + // Caught up with the newest emission: older ones can't come back. + sync.pending.clear(); + return; + } + if (sync.pending.has(value)) return; // stale own echo + // Outside change: forget our emissions. + sync.latest = null; + sync.pending.clear(); + } + if (value !== editor.getHTML()) { editor.chain().setMeta("addToHistory", false).setContent(value, { emitUpdate: false }).run(); } - }, [editor, value]); + }, [editor, value, sync]); // `editable` in useEditor only seeds the initial state; flips afterwards // (e.g. a form toggling into "saving") go through setEditable.