From 890c18924b7fc2c65ad5695d87655e38375129cc Mon Sep 17 00:00:00 2001 From: SecPan Date: Wed, 8 Jul 2026 13:25:23 -0700 Subject: [PATCH 1/2] Port upstream Muya identity flush guard --- .../state/__tests__/flushPendingOps.spec.ts | 34 ++++++++++++++++++- third_party/muya/src/state/index.ts | 9 +++-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/third_party/muya/src/state/__tests__/flushPendingOps.spec.ts b/third_party/muya/src/state/__tests__/flushPendingOps.spec.ts index 5d5c718..c3e9b87 100644 --- a/third_party/muya/src/state/__tests__/flushPendingOps.spec.ts +++ b/third_party/muya/src/state/__tests__/flushPendingOps.spec.ts @@ -1,8 +1,19 @@ // @vitest-environment happy-dom import type Content from '../../block/base/content'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as json1 from 'ot-json1'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Muya } from '../../muya'; +import { asDoc } from '..'; + +vi.mock('../../utils/prism/index', () => ({ + default: {}, + walkTokens: () => null, + loadedLanguages: new Set(), + transformAliasToOrigin: (s: string) => s, + loadLanguage: () => Promise.resolve([]), + search: () => [], +})); // #2938 part 2: `muya.flush()` makes a same-frame edit durable before the // document is swapped out (a tab switch calls setContent within the same frame @@ -108,4 +119,25 @@ describe('muya.flush() — make pending edits durable synchronously (#2938)', () await nextFrame(); expect(muya.getMarkdown().trim()).toBe('two'); }); + + it('does not emit json-change when queued operations compose to identity (#4806)', () => { + const muya = boot('hello\n'); + const jsonState = muya.editor.jsonState; + const op = json1.insertOp([1], asDoc({ name: 'paragraph', text: 'x' } as never))!; + const inverse = json1.type.invert(op); + let changes = 0; + + expect(json1.type.compose(op, inverse)).toBe(null); + muya.eventCenter.on('json-change', () => { + changes += 1; + }); + + // @ts-expect-error — drive the private deferred-flush path directly. + jsonState._operationCache.push(op, inverse); + // @ts-expect-error — this is the crash stack's private flush method. + expect(() => jsonState._flushOperationCache()).not.toThrow(); + + expect(changes).toBe(0); + expect(muya.getMarkdown().trim()).toBe('hello'); + }); }); diff --git a/third_party/muya/src/state/index.ts b/third_party/muya/src/state/index.ts index d7515f1..39bcdf7 100644 --- a/third_party/muya/src/state/index.ts +++ b/third_party/muya/src/state/index.ts @@ -272,8 +272,9 @@ class JSONState { // (acc, current, index, array) to the callback, but // `json1.type.compose` only accepts (op1, op2). Without the // wrapper TS rejects the signature mismatch. - // `compose` returns JSONOp (= null | JSONOpList); a non-empty cache - // (guarded above) always composes to a non-null op. + // `compose` returns JSONOp (= null | JSONOpList). Multiple queued + // operations may cancel each other out (for example during IME + // composition), producing the identity operation (`null`). const op = this._operationCache.reduce( (acc, curr) => json1.type.compose(acc, curr) as JSONOpList, ); @@ -284,6 +285,10 @@ class JSONState { // Clear before emitting: a listener that edits synchronously then starts // a fresh batch instead of mutating the one being flushed. this._operationCache = []; + + if (op === null) + return; + this._muya.eventCenter.emit('json-change', { op, source: 'user', From 30bab4fb4d61311df21ec875c45464fd2dd2d087 Mon Sep 17 00:00:00 2001 From: SecPan Date: Wed, 8 Jul 2026 13:27:36 -0700 Subject: [PATCH 2/2] Port upstream Muya history identity op guard --- .../__tests__/identityOpJsonChange.spec.ts | 75 +++++++++++++++++++ third_party/muya/src/history/index.ts | 5 +- 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 third_party/muya/src/history/__tests__/identityOpJsonChange.spec.ts diff --git a/third_party/muya/src/history/__tests__/identityOpJsonChange.spec.ts b/third_party/muya/src/history/__tests__/identityOpJsonChange.spec.ts new file mode 100644 index 0000000..d64efae --- /dev/null +++ b/third_party/muya/src/history/__tests__/identityOpJsonChange.spec.ts @@ -0,0 +1,75 @@ +// @vitest-environment happy-dom + +import * as json1 from 'ot-json1'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Muya } from '../../muya'; +import { asDoc } from '../../state'; + +vi.mock('../../utils/prism/index', () => ({ + default: {}, + walkTokens: () => null, + loadedLanguages: new Set(), + transformAliasToOrigin: (s: string) => s, + loadLanguage: () => Promise.resolve([]), + search: () => [], +})); + +const bootedHosts: HTMLElement[] = []; + +beforeEach(() => { + window.MUYA_VERSION = 'test'; +}); + +afterEach(() => { + while (bootedHosts.length) + bootedHosts.pop()!.remove(); + document.getSelection()?.removeAllRanges(); + delete (window as Partial).MUYA_VERSION; +}); + +function bootMuya(markdown: string): Muya { + const host = document.createElement('div'); + document.body.appendChild(host); + const muya = new Muya(host, { markdown } as ConstructorParameters[1]); + muya.init(); + bootedHosts.push(muya.domNode); + return muya; +} + +function undoDepth(muya: Muya): number { + // @ts-expect-error — reach into the private stack for test assertions. + return muya.editor.history._stack.undo.length; +} + +describe('identity (null) op via json-change — #4806', () => { + it('updateContents(null) does not crash History and records nothing', () => { + const muya = bootMuya('hello\n'); + + expect(() => muya.editor.updateContents(null, null, 'user')).not.toThrow(); + + expect(muya.getMarkdown().trim()).toBe('hello'); + expect(undoDepth(muya)).toBe(0); + }); + + it('flushing a compose-to-null batch is a no-op (locks #4815)', () => { + const muya = bootMuya('hello\n'); + const jsonState = muya.editor.jsonState; + const op = json1.insertOp([1], asDoc({ name: 'paragraph', text: 'x' } as never))!; + const inverse = json1.type.invert(op); + let changes = 0; + + expect(json1.type.compose(op, inverse)).toBe(null); + muya.eventCenter.on('json-change', () => { + changes += 1; + }); + + // @ts-expect-error — drive the private deferred-flush path directly. + jsonState._operationCache.push(op, inverse); + // @ts-expect-error — the function the crash stack named. + expect(() => jsonState._flushOperationCache()).not.toThrow(); + + expect(changes).toBe(0); + expect(muya.getMarkdown().trim()).toBe('hello'); + expect(undoDepth(muya)).toBe(0); + }); +}); diff --git a/third_party/muya/src/history/index.ts b/third_party/muya/src/history/index.ts index e7033fc..2d405e8 100644 --- a/third_party/muya/src/history/index.ts +++ b/third_party/muya/src/history/index.ts @@ -130,7 +130,7 @@ class History { source, prevDoc, }: { - op: JSONOpList; + op: Nullable; source: string; prevDoc: TState[]; doc: TState[]; @@ -138,6 +138,9 @@ class History { if (this._ignoreChange) return; + if (op == null) + return; + if (!this._options.userOnly || source === 'user') this._record(op, prevDoc); else