diff --git a/README.md b/README.md
index 016b00be78..b1a14e6164 100644
--- a/README.md
+++ b/README.md
@@ -84,6 +84,9 @@
:kr:
+
+ :bangladesh:
+
diff --git a/docs/i18n/README-ar.md b/docs/i18n/README-ar.md
index ea2f0ccf84..3a7f044a1b 100644
--- a/docs/i18n/README-ar.md
+++ b/docs/i18n/README-ar.md
@@ -90,6 +90,9 @@
:kr:
+
+ :bangladesh:
+
diff --git a/docs/i18n/README-bn.md b/docs/i18n/README-bn.md
new file mode 100644
index 0000000000..991d115142
--- /dev/null
+++ b/docs/i18n/README-bn.md
@@ -0,0 +1,201 @@
+

+
+
MarkText
+
+
+
+ :high_brightness: আধুনিক প্রজন্মের Markdown এডিটর :crescent_moon:
+ একটি সাধারণ এবং অত্যাধুনিক ওপেন-সোর্স Markdown এডিটর যা গতি এবং ব্যবহারযোগ্যতার দিকে গুরুত্ত দেয়।
+ Linux, macOS এবং Windows-এর জন্য।
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
স্পনসর
+
+MarkText একটি ওপেন-সোর্স Markdown এডিটর, যা এর কমিউনিটির সহায়তায় এগিয়ে চলছে। MarkText যদি আপনার কাজের ধারা উন্নত করে থাকে, তাহলে অনুগ্রহ করে [প্রজেক্টটিকে স্পনসর করার](https://github.com/sponsors/marktext) কথা বিবেচনা করুন। সব স্পনসরকে ধন্যবাদ ❤️
+
+**বিশেষ স্পনসর**
+
+| [

](https://serpapi.com/?utm_source=marktext) | [আমাদের দ্রুত, সহজ এবং পূর্ণাঙ্গ API দিয়ে Google ও অন্যান্য সার্চ ইঞ্জিন থেকে ফলাফল সংগ্রহ করুন।](https://serpapi.com/?utm_source=marktext) |
+| ------------- |:-------------|
+
+## স্ক্রিনশট
+
+
+
+## বৈশিষ্ট্য
+
+- রিয়েলটাইম প্রিভিউ (WYSIWYG) এবং পরিচ্ছন্ন, আধুনিক ইন্টারফেস আপনাকে গভীর মনোযোগের সাথে লেখার অভিজ্ঞতা দেয়।
+- [CommonMark Spec](https://spec.commonmark.org), [GitHub Flavored Markdown Spec](https://github.github.com/gfm/) এবং আংশিকভাবে [Pandoc markdown](https://pandoc.org/MANUAL.html#pandocs-markdown) সমর্থন করে।
+- Markdown এক্সটেনশন যেমন গণিতের অভিব্যক্তি (KaTeX), front matter এবং emoji সমর্থন করে।
+- দ্রুত লেখার জন্য paragraph ও inline style shortcut সমর্থন করে।
+- **HTML** এবং **PDF** ফাইলে এক্সপোর্ট করা যায়।
+- বিভিন্ন [theme](https://marktext.me/docs/themes): **Cadmium Light**, **Material Dark** ইত্যাদি।
+- বিভিন্ন এডিটিং মোড: **Source Code mode**, **Typewriter mode**, **Focus mode**।
+- ক্লিপবোর্ড থেকে সরাসরি ছবি পেস্ট করা যায়।
+
+## ডাউনলোড ও ইনস্টলেশন
+
+
+
+|  |  |  |
+|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:|
+| [](https://github.com/marktext/marktext/releases/latest) | [](https://github.com/marktext/marktext/releases/latest) | [](https://github.com/marktext/marktext/releases/latest) |
+
+সর্বশেষ সংস্করণের নতুন ফিচারগুলো দেখতে [CHANGELOG](https://marktext.me/docs/changelog) দেখুন।
+
+#### macOS
+
+macOS 11 (Big Sur) বা পরবর্তী সংস্করণ প্রয়োজন। Universal build প্রকাশ করা হয় না, তাই আপনার মেশিনের সঙ্গে মিল থাকা `arm64` বা `x64` installer বেছে নিন।
+
+আপনি [release page](https://github.com/marktext/marktext/releases/latest) থেকে সর্বশেষ `marktext-mac-(arm64|x64)-%version%.dmg` ডাউনলোড করতে পারেন, অথবা [**homebrew cask**](https://github.com/caskroom/homebrew-cask) ব্যবহার করে MarkText ইনস্টল করতে পারেন। Homebrew-Cask ব্যবহার করতে হলে আগে [Homebrew](https://brew.sh/) ইনস্টল করা থাকতে হবে।
+
+```bash
+brew install --cask mark-text
+```
+
+#### Windows
+
+Windows 10 অথবা 11 প্রয়োজন। x64 এবং arm64 - উভয় installer প্রকাশ করা হয়, তাই আপনার মেশিনের architecture অনুযায়ীটি বেছে নিন।
+
+শুধু setup wizard (`marktext-win-(x64|arm64)-%version%-setup.exe`) ডাউনলোড করে ইনস্টল করুন এবং user-only নাকি machine-wide ইনস্টল করবেন তা বেছে নিন। বিকল্পভাবে, [Chocolatey](https://chocolatey.org/) বা [Winget](https://docs.microsoft.com/en-us/windows/package-manager/winget/) এর মতো package manager ব্যবহার করেও MarkText ইনস্টল করতে পারেন।
+
+Chocolatey ব্যবহার করতে চাইলে আগে [Chocolatey](https://chocolatey.org/install) ইনস্টল করা থাকতে হবে:
+
+```bash
+choco install marktext
+```
+
+Winget ব্যবহার করতে চাইলে আগে [Winget](https://docs.microsoft.com/en-us/windows/package-manager/winget/#install-winget) ইনস্টল করা থাকতে হবে:
+
+```bash
+winget install marktext
+```
+
+#### Linux
+
+অনুগ্রহ করে [Linux installation instructions](https://marktext.me/docs/installation) অনুসরণ করুন।
+
+#### অন্যান্য
+
+Linux, macOS এবং Windows-এর সব binary [release page](https://github.com/marktext/marktext/releases/latest) থেকে ডাউনলোড করা যাবে। আপনার সিস্টেমের জন্য কোনো সংস্করণ না থাকলে একটি [issue](https://github.com/marktext/marktext/issues) খুলুন।
+
+## ডেভেলপমেন্ট
+
+আপনি যদি নিজে MarkText build করতে চান, তাহলে আমাদের [build instructions](https://marktext.me/docs/dev/build) দেখুন।
+
+- [ব্যবহারকারী ডকুমেন্টেশন](https://marktext.me/docs/introduction)
+- [ডেভেলপার ডকুমেন্টেশন](https://marktext.me/docs/dev/overview)
+
+MarkText সম্পর্কে আপনার কোনো প্রশ্ন থাকলে issue খুলতে পারেন। Issue খোলার সময় অনুগ্রহ করে ডিফল্ট ফরম্যাটটি ব্যবহার করুন। অবশ্যই, সরাসরি PR পাঠালে সেটিও অত্যন্ত প্রশংসিত হবে।
+
+## অবদান
+
+MarkText এখনো উন্নয়নাধীন, তাই pull request করার আগে [Contributing Guide](../../.github/CONTRIBUTING.md) পড়ে নিন। MarkText-এ নতুন ফিচার যোগ করতে চান? আমাদের [roadmap](https://github.com/marktext/marktext/projects) এবং open issue দেখুন।
+
+## অবদানকারীরা
+
+যারা ইতিমধ্যেই MarkText-এ অবদান রেখেছেন, তাদের সবাইকে ধন্যবাদ [[contributors](https://github.com/marktext/marktext/graphs/contributors)]।
+
+

+
+## লাইসেন্স
+
+[**MIT**](../../LICENSE)।
diff --git a/docs/i18n/README-de.md b/docs/i18n/README-de.md
index 0ca1d68fdd..c5acaad42b 100644
--- a/docs/i18n/README-de.md
+++ b/docs/i18n/README-de.md
@@ -90,6 +90,9 @@
:saudi_arabia:
+
+ :bangladesh:
+
diff --git a/docs/i18n/README-es.md b/docs/i18n/README-es.md
index 4c4f0b8afe..7780e76563 100644
--- a/docs/i18n/README-es.md
+++ b/docs/i18n/README-es.md
@@ -90,6 +90,9 @@
:saudi_arabia:
+
+ :bangladesh:
+
diff --git a/docs/i18n/README-fr.md b/docs/i18n/README-fr.md
index 27be6e8e80..98180a1549 100644
--- a/docs/i18n/README-fr.md
+++ b/docs/i18n/README-fr.md
@@ -90,6 +90,9 @@
:saudi_arabia:
+
+ :bangladesh:
+
diff --git a/docs/i18n/README-jp.md b/docs/i18n/README-jp.md
index 4394d0b26e..768910774c 100644
--- a/docs/i18n/README-jp.md
+++ b/docs/i18n/README-jp.md
@@ -90,6 +90,9 @@
:saudi_arabia:
+
+ :bangladesh:
+
diff --git a/docs/i18n/README-kr.md b/docs/i18n/README-kr.md
index 676c72e3c3..4750cefa29 100644
--- a/docs/i18n/README-kr.md
+++ b/docs/i18n/README-kr.md
@@ -90,6 +90,9 @@
:saudi_arabia:
+
+ :bangladesh:
+
diff --git a/docs/i18n/README-pt.md b/docs/i18n/README-pt.md
index 4f6af02f12..c0dc805f2e 100644
--- a/docs/i18n/README-pt.md
+++ b/docs/i18n/README-pt.md
@@ -90,6 +90,9 @@
:saudi_arabia:
+
+ :bangladesh:
+
diff --git a/docs/i18n/README-tr.md b/docs/i18n/README-tr.md
index 3ed94b8782..bdefccbbf0 100644
--- a/docs/i18n/README-tr.md
+++ b/docs/i18n/README-tr.md
@@ -90,6 +90,9 @@
:saudi_arabia:
+
+ :bangladesh:
+
diff --git a/docs/i18n/README-zh_cn.md b/docs/i18n/README-zh_cn.md
index b8202f1157..c2e64e3161 100644
--- a/docs/i18n/README-zh_cn.md
+++ b/docs/i18n/README-zh_cn.md
@@ -90,6 +90,9 @@
:saudi_arabia:
+
+ :bangladesh:
+
diff --git a/docs/i18n/README-zh_tw.md b/docs/i18n/README-zh_tw.md
index 2fdb91fb71..000fed1c99 100644
--- a/docs/i18n/README-zh_tw.md
+++ b/docs/i18n/README-zh_tw.md
@@ -90,6 +90,9 @@
:saudi_arabia:
+
+ :bangladesh:
+
diff --git a/packages/muya/src/history/__tests__/identityOpJsonChange.spec.ts b/packages/muya/src/history/__tests__/identityOpJsonChange.spec.ts
new file mode 100644
index 0000000000..55e9f6a26e
--- /dev/null
+++ b/packages/muya/src/history/__tests__/identityOpJsonChange.spec.ts
@@ -0,0 +1,77 @@
+// @vitest-environment happy-dom
+
+// Regression for #4806: an identity (`null`) ot-json1 op must never crash
+// History. `compose()` legitimately yields `null` when queued ops cancel out
+// (e.g. IME edits), and both the deferred-flush emitter and the `dispatch`
+// path can surface it through `json-change`. `History` reads `op.length`, so a
+// forwarded `null` threw `Cannot read properties of null (reading 'length')`.
+
+import * as json1 from 'ot-json1';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { Muya } from '../../muya';
+import { asDoc } from '../../state';
+
+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');
+
+ // `Editor.updateContents` deliberately forwards the identity op to
+ // `dispatch`, which emits a `json-change` carrying `op: null`.
+ 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;
+
+ // A real op and its exact inverse compose to the identity op — the
+ // op-level shape of an IME insert cancelled by a delete in one frame.
+ const op = json1.insertOp([1], asDoc({ name: 'paragraph', text: 'x' } as never))!;
+ const inverse = json1.type.invert(op);
+ expect(json1.type.compose(op, inverse)).toBe(null);
+
+ let changes = 0;
+ 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/packages/muya/src/history/index.ts b/packages/muya/src/history/index.ts
index e7033fc523..cce428ce57 100644
--- a/packages/muya/src/history/index.ts
+++ b/packages/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,13 @@ class History {
if (this._ignoreChange)
return;
+ // The identity op (`null`) carries no change to record or
+ // transform. It can still reach here through `json-change` when
+ // queued ops compose away (e.g. IME edits, #4806); `_record`
+ // would otherwise crash reading `op.length`.
+ if (op == null)
+ return;
+
if (!this._options.userOnly || source === 'user')
this._record(op, prevDoc);
else
diff --git a/packages/muya/src/state/index.ts b/packages/muya/src/state/index.ts
index d7515f1b4f..39bcdf7bf6 100644
--- a/packages/muya/src/state/index.ts
+++ b/packages/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',