Skip to content

Fix glossary definition text loss (follow-up to v1.3.3)#31

Merged
juancobo merged 3 commits into
mainfrom
v1.3.4-beta
Jun 10, 2026
Merged

Fix glossary definition text loss (follow-up to v1.3.3)#31
juancobo merged 3 commits into
mainfrom
v1.3.4-beta

Conversation

@juancobo

Copy link
Copy Markdown
Member

A definition could lose everything after the first character or two while typing. Root cause: the glossary chip-resolver observed the same Y.Array the definition editor edits, so each keystroke fired a re-entrant view.dispatch inside the editor's sync, throwing "Calls to EditorView.update are not allowed while an update is in progress" and crashing the sync after the first character.

The resolver now (1) dedupes — it skips dispatch when term_id/title are unchanged, so a definition keystroke fires no dispatch at all — and (2) defers any genuine map change via queueMicrotask so it never lands inside an update cycle. Either defence alone prevents the loss. Verified on staging in a real browser for both new and existing terms; adds tests for the full-text round-trip, the dedupe, a title rename, and the destroyed-view cancellation race.

Copilot AI review requested due to automatic review settings June 10, 2026 20:44
@juancobo juancobo merged commit a56002a into main Jun 10, 2026
@juancobo juancobo deleted the v1.3.4-beta branch June 10, 2026 20:46

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a regression where typing into a glossary definition editor could truncate the persisted Y.Text after 1–2 characters due to a re-entrant view.dispatch triggered from a glossary observeDeep callback during an in-progress CodeMirror update.

Changes:

  • Extracted glossary chip resolution into installGlossaryResolution, adding map-change deduping and deferring dispatch via queueMicrotask to avoid re-entrant updates.
  • Updated MarkdownEditor to use the new resolver attachment helper instead of an inline observeDeep dispatcher.
  • Added jsdom regression tests covering full-text round-trip, dedupe behavior, title rename dispatching, and the destroyed-view microtask race; bumped version strings/docs to v1.3.4-beta.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
tests/glossary-definition-sync.test.ts Adds regression coverage ensuring definition typing fully syncs to Y.Text and that resolution dispatch is deduped/deferred safely.
app/components/ui/MarkdownEditor.tsx Switches glossary resolution wiring to installGlossaryResolution to prevent re-entrant dispatch crashes.
app/components/ui/markdown-editor/glossaryResolution.ts Introduces installGlossaryResolution with dedupe + microtask deferral and a stable map builder/serializer.
README.md Updates displayed version badges to v1.3.4-beta.
CHANGELOG.md Adds v1.3.4-beta release notes describing the glossary definition fix.
app/components/layout/Footer.tsx Updates footer version string to v1.3.4-beta.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +52 to +55
/** Stable serialization of the resolution map, for change detection. */
function serializeGlossaryMap(map: Map<string, string>): string {
return JSON.stringify([...map.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1)));
}
Comment on lines +81 to +90
it("the full definition reaches the Y.Text", async () => {
const { view, def, cleanup } = makeEditor();
const detach = installGlossaryResolution(view, def.doc as Y.Doc);
type(view, SENTENCE);
await flushMicrotasks();
expect(def.toString()).toBe(SENTENCE);
expect(view.state.doc.toString()).toBe(SENTENCE);
detach();
cleanup();
});
Comment on lines +92 to +102
it("DEDUPE: typing a definition fires no resolution dispatch (only the initial one)", async () => {
const { view, def, cleanup, mapDispatches } = makeEditor();
const detach = installGlossaryResolution(view, def.doc as Y.Doc);
const afterInstall = mapDispatches(); // the one initial dispatch
type(view, SENTENCE);
await flushMicrotasks();
// No term_id/title changed, so the resolver must not have dispatched again.
expect(mapDispatches()).toBe(afterInstall);
detach();
cleanup();
});
Comment on lines +104 to +116
it("a title rename DOES dispatch (the map genuinely changed)", async () => {
const { view, def, title, cleanup, mapDispatches } = makeEditor();
const detach = installGlossaryResolution(view, def.doc as Y.Doc);
const before = mapDispatches();
(def.doc as Y.Doc).transact(() => {
title.delete(0, title.length);
title.insert(0, "Able-bodied (revised)");
});
await flushMicrotasks();
expect(mapDispatches()).toBe(before + 1);
detach();
cleanup();
});
Comment on lines +118 to +136
it("destroyed-view race: cleanup before a pending flush is a no-op (no throw)", async () => {
const { glossary, view, def, cleanup, mapDispatches } = makeEditor();
const detach = installGlossaryResolution(view, def.doc as Y.Doc);
const before = mapDispatches();
// Cause a real map change so a flush is genuinely scheduled...
(def.doc as Y.Doc).transact(() => {
const t = new Y.Map<unknown>();
t.set("term_id", "new-term");
t.set("title", new Y.Text("New term"));
t.set("definition", new Y.Text(""));
glossary.push([t]);
});
// ...then tear down synchronously before the microtask drains.
detach();
cleanup();
await expect(flushMicrotasks()).resolves.toBeUndefined();
// The cancelled flush must not have dispatched.
expect(mapDispatches()).toBe(before);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants