'
@@ -246,6 +248,28 @@ describe('createDocumentOutline', () => {
expect(documentOutline.outlineItems.value).toEqual([])
})
+ it('materializes the heading through a pending progressive mount before resolving', async () => {
+ // Only the first block is mounted; the TOC (collected from the JSON
+ // state) still lists the tail heading. selectHeading must mount through
+ // the target's state index before querying the DOM.
+ const { scrollToHeading, documentOutline, editor } = createOutlineHarness({
+ toc: TOC_FIXTURE,
+ headingsHtml: '
Alpha
',
+ })
+ editor.ensureMountedThrough.mockImplementation((index: number) => {
+ if (index >= 2) {
+ editor.domNode.querySelector('.mu-container')!.innerHTML = HEADINGS_FIXTURE
+ }
+ })
+
+ await documentOutline.openOutline()
+ documentOutline.selectHeading('beta')
+
+ expect(editor.ensureMountedThrough).toHaveBeenCalledWith(2)
+ expect(scrollToHeading).toHaveBeenCalledTimes(1)
+ expect((scrollToHeading.mock.calls[0][0] as Element).tagName).toBe('H2')
+ })
+
it('fails safe when the target heading is stale: closes without scrolling', async () => {
const { scrollToHeading, documentOutline, editor } = createOutlineHarness({
toc: TOC_FIXTURE,
diff --git a/src/features/editor/documentOutline.ts b/src/features/editor/documentOutline.ts
index 0e6a5fd..5bcdb5c 100644
--- a/src/features/editor/documentOutline.ts
+++ b/src/features/editor/documentOutline.ts
@@ -26,6 +26,8 @@ interface MuyaTocItem {
content: string
lvl: number
slug: string
+ /** Top-level state index — pre-mount key during a progressive mount. */
+ index: number
}
/**
@@ -50,9 +52,10 @@ export function projectOutlineItems(toc: readonly MuyaTocItem[]): OutlineItem[]
/**
* Muya's TOC slug is NOT stamped onto the heading DOM. getTOC() walks only
- * the top-level scrollPage children, and those blocks render as the DIRECT
+ * the top-level JSON-state nodes, and those blocks render as the DIRECT
* children of the scrollPage root (`.mu-container`), so the flat TOC index
- * maps 1:1 onto this ordered element set. Headings inside blockquotes,
+ * maps 1:1 onto this ordered element set (once the target is mounted —
+ * selectHeading materializes it first). Headings inside blockquotes,
* lists, or raw HTML are NOT direct `.mu-container` children and must stay
* out of the query, or every later index would shift.
*/
@@ -233,6 +236,13 @@ export function createDocumentOutline({
}
const editor = getEditor()
+ // With a progressive mount in flight the heading's DOM may not exist yet
+ // even though the TOC (collected from the JSON state) lists it —
+ // materialize blocks through the heading before resolving its element.
+ const target = tocSnapshot.find(item => item.slug === slug)
+ if (editor && target && typeof target.index === 'number') {
+ editor.ensureMountedThrough(target.index)
+ }
const heading = editor
? resolveOutlineHeadingElement(editor.domNode, tocSnapshot, slug)
: null
diff --git a/src/features/editor/resumePosition.test.ts b/src/features/editor/resumePosition.test.ts
index 456ac8f..caf01d5 100644
--- a/src/features/editor/resumePosition.test.ts
+++ b/src/features/editor/resumePosition.test.ts
@@ -7,9 +7,10 @@ import {
computeResumeScrollTop,
createResumePosition,
} from './resumePosition'
-import type {
- CreateResumePositionRecordOptions,
- ResumePositionRecord,
+import {
+ createResumePositionRecord,
+ type CreateResumePositionRecordOptions,
+ type ResumePositionRecord,
} from '../../lib/resumePositions'
import type { MuyaEditor } from './editorRuntime'
@@ -158,17 +159,29 @@ function buildEditorDom() {
interface ControllerHarnessOptions {
createRecord: (options: CreateResumePositionRecordOptions) => Promise
readPosition?: (docKey: string) => ResumePositionRecord | null
+ /** Logical top-level block count (jsonState), independent of mounted DOM. */
+ logicalBlockCount?: number
}
-function createControllerHarness({ createRecord, readPosition }: ControllerHarnessOptions) {
+function createControllerHarness({
+ createRecord,
+ readPosition,
+ logicalBlockCount = 1,
+}: ControllerHarnessOptions) {
// No ResizeObserver: the open-settle phase and stabilization resolve
// immediately, keeping these tests synchronous and deterministic.
vi.stubGlobal('ResizeObserver', undefined)
const { shell, editorRoot } = buildEditorDom()
const store = new Map()
+ const ensureMountedThrough = vi.fn()
const controller = createResumePosition({
- getEditor: () => ({ domNode: editorRoot }) as unknown as MuyaEditor,
+ getEditor: () =>
+ ({
+ domNode: editorRoot,
+ ensureMountedThrough,
+ editor: { jsonState: { rawState: new Array(logicalBlockCount) } },
+ }) as unknown as MuyaEditor,
isEditorReady: () => true,
getDocumentKey: () => 'doc',
getMarkdown: () => 'markdown',
@@ -178,7 +191,7 @@ function createControllerHarness({ createRecord, readPosition }: ControllerHarne
createRecord,
})
- return { controller, shell, store }
+ return { controller, shell, editorRoot, store, ensureMountedThrough }
}
afterEach(() => {
@@ -329,3 +342,77 @@ describe('persistNow live capture', () => {
expect(captured[0].displayText).toBe('Block zero')
})
})
+
+describe('restore offer during a progressive mount', () => {
+ // A real record whose hash matches the harness markdown, pointing past the
+ // single mounted block — i.e. into the pending tail of a larger document.
+ async function makeMatchingRecord(topBlockIndex: number) {
+ const record = await createResumePositionRecord({
+ markdown: 'markdown',
+ topBlockIndex,
+ topBlockRatio: 0,
+ displayText: 'Resume deep in the document',
+ })
+ expect(record).not.toBeNull()
+ return record!
+ }
+
+ it('offers a pending-tail target without materializing anything', async () => {
+ const record = await makeMatchingRecord(5)
+ const { controller, ensureMountedThrough } = createControllerHarness({
+ createRecord: () => Promise.resolve(null),
+ readPosition: () => record,
+ logicalBlockCount: 10,
+ })
+
+ await controller.startForOpenedDocument()
+
+ // The probe runs on every open without user action: it must never
+ // deep-mount (a near-end index would synchronously rebuild the whole
+ // document). The offer stands on the hash match plus logical bounds.
+ expect(controller.resumeCardVisible.value).toBe(true)
+ expect(ensureMountedThrough).not.toHaveBeenCalled()
+ controller.resetForNewDocument()
+ })
+
+ it('discards an index beyond the logical document without mounting', async () => {
+ const record = await makeMatchingRecord(25)
+ const { controller, ensureMountedThrough } = createControllerHarness({
+ createRecord: () => Promise.resolve(null),
+ readPosition: () => record,
+ logicalBlockCount: 10,
+ })
+
+ await controller.startForOpenedDocument()
+
+ expect(controller.resumeCardVisible.value).toBe(false)
+ expect(ensureMountedThrough).not.toHaveBeenCalled()
+ controller.resetForNewDocument()
+ })
+
+ it('materializes through the target only when the user activates the card', async () => {
+ const record = await makeMatchingRecord(5)
+ const { controller, editorRoot, ensureMountedThrough } = createControllerHarness({
+ createRecord: () => Promise.resolve(null),
+ readPosition: () => record,
+ logicalBlockCount: 10,
+ })
+ ensureMountedThrough.mockImplementation((index: number) => {
+ const container = editorRoot.querySelector('.mu-container')!
+ while (container.children.length <= index) {
+ const paragraph = document.createElement('p')
+ paragraph.textContent = `Block ${container.children.length}`
+ container.append(paragraph)
+ }
+ })
+
+ await controller.startForOpenedDocument()
+ expect(controller.resumeCardVisible.value).toBe(true)
+
+ controller.activateResume()
+
+ expect(ensureMountedThrough).toHaveBeenCalledWith(5)
+ expect(controller.resumeCardVisible.value).toBe(false)
+ controller.resetForNewDocument()
+ })
+})
diff --git a/src/features/editor/resumePosition.ts b/src/features/editor/resumePosition.ts
index cba0207..403fd17 100644
--- a/src/features/editor/resumePosition.ts
+++ b/src/features/editor/resumePosition.ts
@@ -408,27 +408,41 @@ export function createResumePosition({
return
}
- const block = blockContainer.children[record.topBlockIndex]
+ // This probe runs on every open without user action, so it must NEVER
+ // materialize blocks: with a progressive mount in flight, a stored
+ // near-end index would otherwise synchronously deep-mount the document
+ // and reintroduce the very freeze progressive mounting removes. The
+ // stored block may therefore have no DOM yet — the hash match above
+ // proved the document identical, so validate the index against the
+ // LOGICAL top-level count instead and defer materialization to the
+ // user's explicit tap (activateResume).
+ const block = blockContainer.children[record.topBlockIndex] ?? null
if (!block) {
- logger?.debug('resume position discarded: block index out of range', {
- docKey,
- topBlockIndex: record.topBlockIndex,
+ const logicalCount = getEditor()?.editor.jsonState.rawState.length ?? 0
+ if (record.topBlockIndex >= logicalCount) {
+ logger?.debug('resume position discarded: block index out of range', {
+ docKey,
+ topBlockIndex: record.topBlockIndex,
+ })
+ return
+ }
+ // In the pending tail: the target sits past the synchronous mount
+ // prefix — hundreds of blocks, far beyond the min-distance threshold —
+ // so eligibility holds by construction and the rect check is skipped.
+ } else {
+ const containerRect = scrollContainer.getBoundingClientRect()
+ const blockRect = block.getBoundingClientRect()
+ const target = computeResumeScrollTop({
+ scrollTop: scrollContainer.scrollTop,
+ containerTop: containerRect.top,
+ blockTop: blockRect.top,
+ blockHeight: blockRect.height,
+ ratio: record.topBlockRatio,
})
- return
- }
-
- const containerRect = scrollContainer.getBoundingClientRect()
- const blockRect = block.getBoundingClientRect()
- const target = computeResumeScrollTop({
- scrollTop: scrollContainer.scrollTop,
- containerTop: containerRect.top,
- blockTop: blockRect.top,
- blockHeight: blockRect.height,
- ratio: record.topBlockRatio,
- })
- if (target < scrollContainer.clientHeight * RESUME_MIN_DISTANCE_VIEWPORTS) {
- logger?.debug('resume position skipped: too close to the top', { docKey })
- return
+ if (target < scrollContainer.clientHeight * RESUME_MIN_DISTANCE_VIEWPORTS) {
+ logger?.debug('resume position skipped: too close to the top', { docKey })
+ return
+ }
}
// The user started scrolling or editing while validation ran: they are
@@ -610,6 +624,10 @@ export function createResumePosition({
const scrollContainer = getScrollContainer()
const blockContainer = getBlockContainer()
+ // Materialization happens HERE, on the user's explicit tap — the offer
+ // deliberately never mounts (a stored near-end index would deep-mount
+ // the whole document during the automatic probe). No-op once mounted.
+ getEditor()?.ensureMountedThrough(target.topBlockIndex)
const block = blockContainer?.children[target.topBlockIndex] ?? null
if (!scrollContainer || !block) {
logger?.debug('resume activation aborted: target unresolvable')
diff --git a/src/types/muya-core.d.ts b/src/types/muya-core.d.ts
index aefa5b2..f647d6b 100644
--- a/src/types/muya-core.d.ts
+++ b/src/types/muya-core.d.ts
@@ -20,6 +20,12 @@ declare module '@muyajs/core' {
lvl: number
slug: string
githubSlug: string
+ /**
+ * Top-level state index of the heading. During a progressive mount the
+ * heading's DOM may not exist yet; pass this to
+ * `muya.ensureMountedThrough(index)` before resolving the element.
+ */
+ index: number
}
export interface IMuyaClipboard {
@@ -71,6 +77,12 @@ declare module '@muyajs/core' {
activeContentBlock: unknown
clipboard: IMuyaClipboard
selection: IMuyaSelection
+ /**
+ * The complete logical document. `rawState` is the live state root
+ * (no clone) — read-only consumers only, e.g. bounds checks against
+ * the top-level block count during a progressive mount.
+ */
+ jsonState: { readonly rawState: readonly unknown[] }
}
constructor(element: HTMLElement, options?: Record)
init(): void
@@ -99,6 +111,11 @@ declare module '@muyajs/core' {
selectAll(): void
search(value: string, opts?: IMuyaSearchOptions): IMuyaSearchState
find(action: 'previous' | 'next'): IMuyaSearchState
+ /**
+ * Materialize blocks through the given top-level state index (progressive
+ * mount, marktext#4887). No-op when the index is already mounted.
+ */
+ ensureMountedThrough(index: number): void
destroy(): void
}
diff --git a/third_party/muya/src/__tests__/setCursorByOffset.spec.ts b/third_party/muya/src/__tests__/setCursorByOffset.spec.ts
index 3aefbde..76431ce 100644
--- a/third_party/muya/src/__tests__/setCursorByOffset.spec.ts
+++ b/third_party/muya/src/__tests__/setCursorByOffset.spec.ts
@@ -135,4 +135,24 @@ describe('muya.setCursorByOffset() (PG2)', () => {
const after = muya.getHistory();
expect(after.stack.undo.length).toBe(before.stack.undo.length);
});
+
+ it('PG2: restores a caret beyond the progressive-mount prefix (review repro 2)', () => {
+ // 600 one-line paragraphs; markdown line 1100 is paragraph 550 —
+ // beyond the 512-weight synchronous prefix, so its mount chunk has
+ // not run at init time (#4887).
+ const markdown = `${Array.from({ length: 600 }, (_, i) => `line ${i}`).join('\n\n')}\n`;
+ const muya = bootMuya(markdown);
+ expect(muya.domNode.querySelectorAll('p.mu-paragraph').length).toBeLessThan(600);
+
+ const restored = muya.setCursorByOffset({
+ anchor: { line: 1100, ch: 3 },
+ focus: { line: 1100, ch: 3 },
+ });
+
+ expect(restored).toBe(true);
+ // The restore mounted only up to its target, not the whole remainder.
+ const mounted = muya.domNode.querySelectorAll('p.mu-paragraph').length;
+ expect(mounted).toBeGreaterThanOrEqual(551);
+ expect(mounted).toBeLessThan(600);
+ });
});
diff --git a/third_party/muya/src/block/base/content.ts b/third_party/muya/src/block/base/content.ts
index d242fac..d6b8a68 100644
--- a/third_party/muya/src/block/base/content.ts
+++ b/third_party/muya/src/block/base/content.ts
@@ -522,8 +522,10 @@ class Content extends TreeNode {
) {
event.preventDefault();
event.stopPropagation();
- if (nextContentBlock) {
- cursorBlock = nextContentBlock;
+ const targetContentBlock
+ = nextContentBlock ?? this.resolveNextContentInContext();
+ if (targetContentBlock) {
+ cursorBlock = targetContentBlock;
}
// Only append a trailing paragraph when the last block has content.
// Otherwise ArrowDown in an already-empty last paragraph would keep
diff --git a/third_party/muya/src/block/base/format.ts b/third_party/muya/src/block/base/format.ts
index 1f6cbe7..eba31af 100644
--- a/third_party/muya/src/block/base/format.ts
+++ b/third_party/muya/src/block/base/format.ts
@@ -1531,7 +1531,10 @@ class Format extends Content {
this.muya.editor.history.markInputBoundary('deleteContentForward', null);
- const nextBlock = this.nextContentInContext();
+ // Pending-mount aware: at the frontier the paragraph to merge with
+ // may not be materialized yet (#4887) — a plain context walk would
+ // silently swallow the keystroke.
+ const nextBlock = this.resolveNextContentInContext();
if (!nextBlock || nextBlock.blockName !== 'paragraph.content') {
// If the next block is code content or table cell, nothing need to do.
event.preventDefault();
@@ -1847,6 +1850,18 @@ class Format extends Content {
if (!scrollPage)
return;
+ // Hosts without a footnote tool never consume the event — skip the
+ // whole-document preparation below instead of freezing a large file
+ // for nothing (#4887).
+ if (!this.muya.eventCenter.hasListeners('muya-footnote-tool'))
+ return;
+
+ // The identifier -> definition map must cover the WHOLE document: a
+ // definition still in the unmounted tail (#4887) resolving as
+ // "missing" would make the tool offer Create and duplicate it. Same
+ // documented whole-document trade as search.
+ scrollPage.flushPendingMount();
+
// Collect the first definition for each identifier — duplicates in
// the document share the same `#fn-{N}` target on the HTML side.
const footnotes = new Map();
diff --git a/third_party/muya/src/block/base/treeNode.ts b/third_party/muya/src/block/base/treeNode.ts
index 1f0829c..8855ca7 100644
--- a/third_party/muya/src/block/base/treeNode.ts
+++ b/third_party/muya/src/block/base/treeNode.ts
@@ -168,6 +168,49 @@ class TreeNode implements ILinkedNode {
return parent.nextContentInContext();
}
+ /**
+ * Like `nextContentInContext`, but pending-mount aware (#4887): the
+ * traversal only sees mounted blocks, so at the progressive-mount
+ * frontier a still-pending successor reads as "no successor" and
+ * end-of-document fallbacks (append a trailing paragraph) would insert
+ * it between the mounted prefix and the logical tail. Keep
+ * materializing until a content appears or the mount stops making
+ * progress — a single step is not enough, because the next top-level
+ * block can be an empty container with no content descendant. At the
+ * genuine document end this mounts nothing and returns null.
+ *
+ * Call it on a CONTENT receiver (as `nextContentInContext` itself): the
+ * sibling walk starts at the receiver's parent, so a top-level Parent
+ * receiver would skip its own successors.
+ */
+ resolveNextContentInContext(): Nullable {
+ let next = this.nextContentInContext();
+ if (next)
+ return next;
+
+ const { scrollPage } = this;
+ if (!scrollPage)
+ return null;
+
+ // `nextContentInContext` has already scanned every MOUNTED
+ // successor — including mounted empty containers — so the search
+ // can only advance by extending the mount frontier itself. Ask for
+ // the first UNMOUNTED index (`children.length`) each round: any
+ // request below the frontier is a no-op (`ensureMountedThrough`
+ // returns early for mounted indices), which would misread the
+ // pending tail as the document end.
+ while (!next) {
+ const mounted = scrollPage.children.length;
+ scrollPage.ensureMountedThrough(mounted);
+ if (scrollPage.children.length === mounted)
+ break;
+
+ next = this.nextContentInContext();
+ }
+
+ return next;
+ }
+
/**
* Weather `this` is the only child of its parent.
*/
diff --git a/third_party/muya/src/block/commonMark/headingCopyLink/index.ts b/third_party/muya/src/block/commonMark/headingCopyLink/index.ts
index 519949a..e38a442 100644
--- a/third_party/muya/src/block/commonMark/headingCopyLink/index.ts
+++ b/third_party/muya/src/block/commonMark/headingCopyLink/index.ts
@@ -94,14 +94,29 @@ class HeadingCopyLink extends TreeNode {
}
// Emit `heading-copy-link` with the heading's stable slug. At activation
- // time the attachment's parent is the heading block.
+ // time the attachment's parent is the heading block. Slugs are keyed on
+ // the heading's STATE node (see state/getTOC.ts), so resolve the block to
+ // its top-level state entry — headings are always top-level, and the
+ // block's path starts with its top-level index.
private _activate() {
const heading = this.parent;
if (!heading)
return;
+ // The block path reflects same-frame tree mutations immediately,
+ // while `rawState` only updates on the rAF flush — drain the batch
+ // so the index resolves against the same document revision.
+ this.muya.editor.jsonState.flush();
+
+ const index = heading.path[0];
+ const node = typeof index === 'number'
+ ? this.muya.editor.jsonState.rawState[index]
+ : undefined;
+ if (!node)
+ return;
+
this.muya.eventCenter.emit('heading-copy-link', {
- key: stableSlug(heading),
+ key: stableSlug(node),
});
}
diff --git a/third_party/muya/src/block/content/codeBlockContent/index.ts b/third_party/muya/src/block/content/codeBlockContent/index.ts
index e3643b2..9440191 100644
--- a/third_party/muya/src/block/content/codeBlockContent/index.ts
+++ b/third_party/muya/src/block/content/codeBlockContent/index.ts
@@ -259,7 +259,7 @@ class CodeBlockContent extends Content {
// Shift + Enter to jump out of code block.
if (event.shiftKey) {
let cursorBlock;
- const nextContentBlock = this.nextContentInContext();
+ const nextContentBlock = this.resolveNextContentInContext();
if (nextContentBlock) {
cursorBlock = nextContentBlock;
}
diff --git a/third_party/muya/src/block/content/tableCell/__tests__/tabHandler.spec.ts b/third_party/muya/src/block/content/tableCell/__tests__/tabHandler.spec.ts
index 04a722a..1279052 100644
--- a/third_party/muya/src/block/content/tableCell/__tests__/tabHandler.spec.ts
+++ b/third_party/muya/src/block/content/tableCell/__tests__/tabHandler.spec.ts
@@ -10,7 +10,9 @@ import TableCellContent from '../index';
// We drive the handler directly off the prototype with a structurally
// typed `this` so we don't need the full Muya bootstrap (which needs a
// real DOM). The handler only reads `event.shiftKey` and calls
-// `previousContentInContext` / `nextContentInContext` on `this`.
+// `previousContentInContext` / `resolveNextContentInContext` on `this`
+// (the latter is the pending-mount-aware wrapper, #4887; with no
+// scrollPage on the stub it degrades to the plain context walk).
// Structural neighbour shape — the table-cell tab handler only calls
// `setCursor` on whatever `prev`/`next` resolves to.
@@ -22,6 +24,8 @@ function makeFakeCell(prev: IFakeNeighbour | null, next: IFakeNeighbour | null)
return {
nextContentInContext: vi.fn(() => next),
previousContentInContext: vi.fn(() => prev),
+ resolveNextContentInContext:
+ TableCellContent.prototype.resolveNextContentInContext,
};
}
diff --git a/third_party/muya/src/block/content/tableCell/index.ts b/third_party/muya/src/block/content/tableCell/index.ts
index 14730db..ed75082 100644
--- a/third_party/muya/src/block/content/tableCell/index.ts
+++ b/third_party/muya/src/block/content/tableCell/index.ts
@@ -97,7 +97,7 @@ class TableCellContent extends Format {
}
else {
const lastCellContent = row.lastContentInDescendant();
- const nextContent = lastCellContent?.nextContentInContext();
+ const nextContent = lastCellContent?.resolveNextContentInContext();
if (nextContent) {
cursorBlock = nextContent;
@@ -176,8 +176,10 @@ class TableCellContent extends Format {
}
else {
let cursorBlock = null;
- if (tableNextContent) {
- cursorBlock = tableNextContent;
+ const nextContent
+ = tableNextContent ?? this.resolveNextContentInContext();
+ if (nextContent) {
+ cursorBlock = nextContent;
}
else {
const state = {
@@ -243,9 +245,13 @@ class TableCellContent extends Format {
// always pass a KeyboardEvent in practice. The structural check just
// keeps unit tests that pass a partial event object passing.
const isShiftTab = 'shiftKey' in event && event.shiftKey === true;
+ // Forward Tab is pending-mount aware (#4887): from the last cell it
+ // must reach the table's successor even when that block is still in
+ // the unmounted tail. Shift+Tab walks backwards through the always-
+ // mounted prefix and needs nothing.
const cursorBlock = isShiftTab
? this.previousContentInContext()
- : this.nextContentInContext();
+ : this.resolveNextContentInContext();
if (cursorBlock)
cursorBlock.setCursor(0, 0, true);
diff --git a/third_party/muya/src/block/scrollPage/__tests__/chunkedMount.spec.ts b/third_party/muya/src/block/scrollPage/__tests__/chunkedMount.spec.ts
new file mode 100644
index 0000000..b039803
--- /dev/null
+++ b/third_party/muya/src/block/scrollPage/__tests__/chunkedMount.spec.ts
@@ -0,0 +1,803 @@
+// @vitest-environment happy-dom
+
+import * as json1 from 'ot-json1';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { Muya } from '../../../muya';
+
+// Chunked initial mount (#4887): documents beyond the synchronous prefix
+// finish mounting in scheduled chunks; whole-tree consumers force the rest.
+
+const SECTIONS = 600;
+
+function buildDoc(): string {
+ return `${Array.from({ length: SECTIONS }, (_, i) => `Paragraph ${i}`).join('\n\n')}\n`;
+}
+
+function mountedParagraphs(muya: Muya): number {
+ return muya.domNode.querySelectorAll('p.mu-paragraph').length;
+}
+
+// Exact sequence equality between the rendered paragraphs and the state —
+// catches duplication, loss, and reordering that substring checks miss.
+function expectDomMatchesState(muya: Muya) {
+ const dom = Array.from(muya.domNode.querySelectorAll('p.mu-paragraph')).map(
+ node => node.textContent?.trim(),
+ );
+ const state = muya
+ .getState()
+ .filter(entry => entry.name === 'paragraph')
+ .map(entry => (entry as { text: string }).text);
+ expect(dom).toEqual(state);
+}
+
+const bootedHosts: HTMLElement[] = [];
+
+beforeEach(() => {
+ window.MUYA_VERSION = 'test';
+ vi.useFakeTimers();
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+ while (bootedHosts.length)
+ bootedHosts.pop()!.remove();
+ document.getSelection()?.removeAllRanges();
+});
+
+function bootMuya(): Muya {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const muya = new Muya(host, { markdown: buildDoc() } as ConstructorParameters[1]);
+ muya.init();
+ bootedHosts.push(muya.domNode);
+ return muya;
+}
+
+describe('scrollPage chunked mount', () => {
+ it('mounts a prefix synchronously and the rest in scheduled chunks', () => {
+ const muya = bootMuya();
+
+ expect(mountedParagraphs(muya)).toBe(512);
+
+ vi.runAllTimers();
+
+ expect(mountedParagraphs(muya)).toBe(SECTIONS);
+ muya.destroy();
+ });
+
+ it('flushes the pending tail for end-of-document consumers', () => {
+ const muya = bootMuya();
+ expect(mountedParagraphs(muya)).toBe(512);
+
+ const last = muya.editor.scrollPage!.lastContentInDescendant();
+
+ expect(mountedParagraphs(muya)).toBe(SECTIONS);
+ expect(last?.text).toBe(`Paragraph ${SECTIONS - 1}`);
+ muya.destroy();
+ });
+
+ it('serializes the full document while a mount is pending', () => {
+ const muya = bootMuya();
+ expect(mountedParagraphs(muya)).toBe(512);
+
+ // Markdown comes from jsonState, not the DOM — never truncated.
+ expect(muya.getMarkdown()).toContain(`Paragraph ${SECTIONS - 1}`);
+ muya.destroy();
+ });
+
+ it('updateState cancels a pending mount instead of appending stale blocks', () => {
+ const muya = bootMuya();
+ expect(mountedParagraphs(muya)).toBe(512);
+
+ muya.setContent('only one\n');
+ vi.runAllTimers();
+
+ expect(mountedParagraphs(muya)).toBe(1);
+ expect(muya.domNode.textContent).not.toContain('Paragraph 599');
+ muya.destroy();
+ });
+
+ it('reports the complete TOC while a mount is pending (review repro 1)', () => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const markdown = `${Array.from({ length: 599 }, (_, i) => `Paragraph ${i}`).join('\n\n')}\n\n# Tail Heading\n`;
+ const muya = new Muya(host, { markdown } as ConstructorParameters[1]);
+ muya.init();
+ bootedHosts.push(muya.domNode);
+
+ expect(mountedParagraphs(muya)).toBeLessThan(599);
+ const toc = muya.getTOC();
+ expect(toc).toHaveLength(1);
+ expect(toc[0].content).toBe('Tail Heading');
+ muya.destroy();
+ });
+
+ it('mounts a detached host to completion (review repro 4)', () => {
+ const host = document.createElement('div');
+ // Never appended to the document.
+ const muya = new Muya(host, { markdown: buildDoc() } as ConstructorParameters[1]);
+ muya.init();
+
+ expect(mountedParagraphs(muya)).toBe(512);
+ vi.runAllTimers();
+ expect(mountedParagraphs(muya)).toBe(SECTIONS);
+ muya.destroy();
+ });
+
+ it('ensureMountedThrough mounts only up to the requested index', () => {
+ const muya = bootMuya();
+ expect(mountedParagraphs(muya)).toBe(512);
+
+ muya.editor.scrollPage!.ensureMountedThrough(549);
+
+ const mounted = mountedParagraphs(muya);
+ expect(mounted).toBeGreaterThanOrEqual(550);
+ expect(mounted).toBeLessThan(SECTIONS);
+ vi.runAllTimers();
+ expect(mountedParagraphs(muya)).toBe(SECTIONS);
+ muya.destroy();
+ });
+
+ it('an insertion during the mount window keeps mounting the correct tail', () => {
+ const muya = bootMuya();
+ expect(mountedParagraphs(muya)).toBe(512);
+
+ // Simulate an edit inserting a top-level block (Enter/paste path):
+ // live tree and state change at the same index, so the pending tail
+ // keeps mounting without any flush and without duplication.
+ const scrollPage = muya.editor.scrollPage!;
+ const newBlock = (scrollPage.constructor as typeof import('../index').ScrollPage)
+ .loadBlock('paragraph')
+ .create(muya, { name: 'paragraph', text: 'inserted' } as never);
+ scrollPage.append(newBlock as never, 'user');
+
+ vi.runAllTimers();
+ expect(mountedParagraphs(muya)).toBe(SECTIONS + 1);
+ expect(muya.domNode.textContent).toContain(`Paragraph ${SECTIONS - 1}`);
+ muya.destroy();
+ });
+
+ it('removing a mounted block during the window stays consistent (review repro 3)', () => {
+ const muya = bootMuya();
+ expect(mountedParagraphs(muya)).toBe(512);
+
+ // Deletion goes through the block's own remove(), which bypasses any
+ // ScrollPage override — the live-cursor design must not care.
+ const first = muya.editor.scrollPage!.firstChild as { remove: (source?: string) => void };
+ first.remove('user');
+ expect(mountedParagraphs(muya)).toBe(511);
+
+ muya.editor.scrollPage!.ensureMountedThrough(511);
+ expect(mountedParagraphs(muya)).toBe(512);
+
+ vi.runAllTimers();
+ expect(mountedParagraphs(muya)).toBe(SECTIONS - 1);
+ expectDomMatchesState(muya);
+ muya.destroy();
+ });
+
+ it('an op targeting the unmounted tail is applied exactly once (review blocker 1)', () => {
+ const muya = bootMuya();
+ expect(mountedParagraphs(muya)).toBe(512);
+
+ // Insert at index 550 — beyond the mounted prefix. updateContents
+ // must mount the target from the PRE-dispatch state, otherwise the
+ // DOM drop re-applies the op to a block built from the updated state.
+ muya.editor.updateContents(
+ json1.insertOp([550], { name: 'paragraph', text: 'remote inserted' } as never)!,
+ null,
+ 'api',
+ );
+
+ vi.runAllTimers();
+ expect(mountedParagraphs(muya)).toBe(SECTIONS + 1);
+ expectDomMatchesState(muya);
+ muya.destroy();
+ });
+
+ it('a text edit into the unmounted tail is applied exactly once (review blocker 1)', () => {
+ const muya = bootMuya();
+ expect(mountedParagraphs(muya)).toBe(512);
+
+ muya.editor.updateContents(
+ json1.editOp([560, 'text'], 'text-unicode', ['Paragraph 560'.length, 'x'])!,
+ null,
+ 'api',
+ );
+
+ vi.runAllTimers();
+ expectDomMatchesState(muya);
+ expect(muya.domNode.textContent).toContain('Paragraph 560x');
+ expect(muya.domNode.textContent).not.toContain('Paragraph 560xx');
+ muya.destroy();
+ });
+
+ it('tail blocks never share meta objects with the state (review: shared meta)', () => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const parts = Array.from({ length: 560 }, (_, i) => `Paragraph ${i}`);
+ parts[550] = '```js\ncode\n```';
+ const muya = new Muya(host, {
+ markdown: `${parts.join('\n\n')}\n`,
+ } as ConstructorParameters[1]);
+ muya.init();
+ bootedHosts.push(muya.domNode);
+
+ muya.editor.scrollPage!.ensureMountedThrough(550);
+ const block = muya.editor.scrollPage!.find(550) as unknown as { meta?: object };
+ const stateNode = muya.editor.jsonState.rawState[550] as { meta?: object };
+
+ expect(block.meta).toBeDefined();
+ expect(stateNode.meta).toBeDefined();
+ // A shared object would let block-level edits rewrite the JSON state
+ // ahead of their op — the op then applies on top of itself.
+ expect(block.meta).not.toBe(stateNode.meta);
+ muya.destroy();
+ });
+
+ it('a replace at the mount boundary applies exactly once (review: boundary replace)', () => {
+ const muya = bootMuya();
+ expect(mountedParagraphs(muya)).toBe(512);
+
+ muya.editor.updateContents(
+ json1.replaceOp(
+ [550],
+ { name: 'paragraph', text: 'Paragraph 550' } as never,
+ { name: 'paragraph', text: 'replacement' } as never,
+ )!,
+ null,
+ 'api',
+ );
+
+ vi.runAllTimers();
+ expectDomMatchesState(muya);
+ const texts = Array.from(muya.domNode.querySelectorAll('p.mu-paragraph')).map(
+ node => node.textContent?.trim(),
+ );
+ expect(texts.filter(text => text === 'replacement')).toHaveLength(1);
+ expect(texts).toContain('Paragraph 551');
+ muya.destroy();
+ });
+
+ it('undo flushes a same-frame edit into the history first (review: swallowed edit)', () => {
+ const muya = bootMuya();
+
+ // A recorded edit deep in the tail — undoing it triggers the
+ // on-demand mount path.
+ muya.editor.updateContents(
+ json1.editOp([550, 'text'], 'text-unicode', ['Paragraph 550'.length, '!'])!,
+ null,
+ 'user',
+ );
+ // Step past the history coalescing window so the next edit is its
+ // own entry.
+ vi.advanceTimersByTime(5000);
+
+ // A same-frame edit that has NOT flushed through the rAF batch yet.
+ const first = muya.editor.scrollPage!.firstContentInDescendant()!;
+ muya.editor.activeContentBlock = first as never;
+ first.setCursor(0, 0, true);
+ (first as unknown as { text: string }).text = 'Paragraph 0 edited';
+
+ muya.undo();
+
+ // The pending edit must have entered the stack and been popped —
+ // not applied while its history entry was silently dropped.
+ expect((first as unknown as { text: string }).text).toBe('Paragraph 0');
+ expect(muya.getHistory().stack.undo.length).toBeGreaterThan(0);
+ muya.destroy();
+ });
+
+ it('redo with a pending same-frame edit invalidates quietly (review: redo crash)', () => {
+ const muya = bootMuya();
+
+ // A recorded edit, undone — leaves exactly one redo entry.
+ muya.editor.updateContents(
+ json1.editOp([550, 'text'], 'text-unicode', ['Paragraph 550'.length, '!'])!,
+ null,
+ 'user',
+ );
+ vi.advanceTimersByTime(5000);
+ muya.undo();
+ expect(muya.getHistory().stack.redo.length).toBe(1);
+
+ // A new edit still sitting in the rAF batch. Flushing it inside
+ // redo() records it — which clears the redo stack; redo must then
+ // no-op instead of popping the emptied stack.
+ const first = muya.editor.scrollPage!.firstContentInDescendant()!;
+ muya.editor.activeContentBlock = first as never;
+ first.setCursor(0, 0, true);
+ (first as unknown as { text: string }).text = 'Paragraph 0 edited';
+
+ expect(() => muya.redo()).not.toThrow();
+ expect((first as unknown as { text: string }).text).toBe('Paragraph 0 edited');
+ expect(muya.getHistory().stack.redo.length).toBe(0);
+ muya.destroy();
+ });
+
+ it('a pending first edit is undoable immediately (review: flush before stack check)', () => {
+ const muya = bootMuya();
+ expect(muya.getHistory().stack.undo.length).toBe(0);
+
+ const first = muya.editor.scrollPage!.firstContentInDescendant()!;
+ muya.editor.activeContentBlock = first as never;
+ first.setCursor(0, 0, true);
+ (first as unknown as { text: string }).text = 'Paragraph 0 edited';
+
+ // The edit has not flushed through the rAF batch, so the undo stack
+ // is still empty — undo() must flush first, then pop that entry.
+ muya.undo();
+
+ expect((first as unknown as { text: string }).text).toBe('Paragraph 0');
+ muya.destroy();
+ });
+
+ it('setContent drops the old selection so focus() cannot deep-mount (review blocker 2)', () => {
+ const muya = bootMuya();
+ expect(mountedParagraphs(muya)).toBe(512);
+
+ // Park the caret deep in the document (mounts through it).
+ muya.setCursorByOffset({
+ anchor: { line: 1100, ch: 3 },
+ focus: { line: 1100, ch: 3 },
+ });
+ expect(mountedParagraphs(muya)).toBeGreaterThanOrEqual(551);
+
+ // Replace the document; the stale anchorPath must not survive into
+ // the new tree, or focus() would force-mount up to the old caret.
+ muya.setContent(buildDoc());
+ muya.editor.focus();
+ expect(mountedParagraphs(muya)).toBe(512);
+
+ vi.runAllTimers();
+ expect(mountedParagraphs(muya)).toBe(SECTIONS);
+ muya.destroy();
+ });
+
+ it('clicking below the frontier is inert while pending, appends after completion (review: blank-click)', () => {
+ const muya = bootMuya();
+ expect(mountedParagraphs(muya)).toBe(512);
+
+ // muya's isMouseEvent guard checks the `x` alias, which happy-dom's
+ // MouseEvent does not implement.
+ const makeClick = () => {
+ const click = new MouseEvent('click', {
+ clientY: 10_000,
+ bubbles: true,
+ cancelable: true,
+ });
+ if (!('x' in click))
+ Object.defineProperty(click, 'x', { value: 0 });
+ return click;
+ };
+
+ // While the tail is pending, the area below the frontier is NOT
+ // blank — the document continues there. No insert (the paragraph
+ // would land at index 512, mid-document) and no forced mount (a
+ // container tap must not freeze a large file).
+ muya.editor.scrollPage!.domNode!.dispatchEvent(makeClick());
+ muya.editor.jsonState.flush();
+ expect(muya.editor.jsonState.rawState).toHaveLength(SECTIONS);
+ expect(mountedParagraphs(muya)).toBe(512);
+
+ // Once the background mount completes, the same click is a real
+ // end-of-document gesture again.
+ vi.runAllTimers();
+ muya.editor.scrollPage!.domNode!.dispatchEvent(makeClick());
+ muya.editor.jsonState.flush();
+ const states = muya.editor.jsonState.rawState;
+ expect(states).toHaveLength(SECTIONS + 1);
+ expect((states[SECTIONS] as { text: string }).text).toBe('');
+ muya.destroy();
+ });
+
+ it('arrow-down across an empty container at the frontier keeps mounting to a content (review: empty container)', () => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const muya = new Muya(host, { markdown: 'seed\n' } as ConstructorParameters[1]);
+ muya.init();
+ bootedHosts.push(muya.domNode);
+
+ // 512 paragraphs, an EMPTY container (no content descendant), then a
+ // trailing paragraph — one mount step past the frontier lands on the
+ // empty container, which is NOT the document end.
+ const states = [
+ ...Array.from({ length: 512 }, (_, i) => ({
+ name: 'paragraph',
+ text: `Paragraph ${i}`,
+ })),
+ { name: 'block-quote', children: [] },
+ { name: 'paragraph', text: 'Paragraph after empty container' },
+ ];
+ muya.setContent(states as never);
+ expect(mountedParagraphs(muya)).toBe(512);
+
+ const frontier = (muya.editor.scrollPage!.find(511) as unknown as {
+ lastContentInDescendant: () => {
+ text: string;
+ setCursor: (start: number, end: number, focus?: boolean) => void;
+ arrowHandler: (event: Event) => void;
+ };
+ }).lastContentInDescendant();
+ expect(frontier.text).toBe('Paragraph 511');
+
+ muya.editor.activeContentBlock = frontier as never;
+ frontier.setCursor(0, 0, true);
+ frontier.arrowHandler(
+ new KeyboardEvent('keydown', { key: 'ArrowDown', cancelable: true }),
+ );
+ muya.editor.jsonState.flush();
+
+ // No paragraph inserted between the prefix and the logical tail.
+ expect(muya.editor.jsonState.rawState).toHaveLength(514);
+ expect(muya.domNode.textContent).toContain('Paragraph after empty container');
+ vi.runAllTimers();
+ expectDomMatchesState(muya);
+ muya.destroy();
+ });
+
+ it('the footnote tool sees definitions in the unmounted tail (review: footnote create)', () => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const parts = Array.from({ length: 599 }, (_, i) => `Paragraph ${i}`);
+ parts[0] = 'Intro with a footnote[^tail] reference.';
+ const markdown = `${parts.join('\n\n')}\n\n[^tail]: The definition lives in the tail.\n`;
+ const muya = new Muya(host, {
+ markdown,
+ footnote: true,
+ } as ConstructorParameters[1]);
+ muya.init();
+ bootedHosts.push(muya.domNode);
+ expect(mountedParagraphs(muya)).toBeLessThan(599);
+
+ const payloads: { footnotes: Map }[] = [];
+ muya.on('muya-footnote-tool', ((payload: { footnotes: Map }) =>
+ void payloads.push(payload)) as never);
+
+ // The identifier→definition map must cover the WHOLE document: a
+ // definition in the unmounted tail resolving as "missing" would make
+ // the tool offer Create and duplicate it.
+ const reference = document.createElement('sup');
+ reference.id = 'noteref-tail';
+ const first = muya.editor.scrollPage!.firstContentInDescendant()!;
+ (first as unknown as {
+ _emitFootnoteToolEvent: (el: HTMLElement) => void;
+ })._emitFootnoteToolEvent(reference);
+
+ expect(payloads).toHaveLength(1);
+ expect(payloads[0].footnotes.has('tail')).toBe(true);
+ muya.destroy();
+ });
+
+ it('heading copy-link resolves against the flushed state (review: index skew)', () => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const muya = new Muya(host, {
+ markdown: 'Paragraph 0\n\n# Section\n\nParagraph 2\n',
+ } as ConstructorParameters[1]);
+ muya.init();
+ bootedHosts.push(muya.domNode);
+
+ const slug = muya.getTOC()[0].slug;
+ const handler = vi.fn();
+ muya.on('heading-copy-link', handler);
+
+ // A structural edit whose op still sits in the rAF batch: the live
+ // tree moves the heading to index 0 while rawState keeps it at 1 —
+ // resolving the block path against the unflushed state would slug
+ // the wrong node.
+ (muya.editor.scrollPage!.firstChild as unknown as {
+ remove: (source?: string) => void;
+ }).remove('user');
+
+ muya.domNode
+ .querySelector('[class*="copy-header-link"]')!
+ .dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ expect(handler.mock.calls[0][0]).toEqual({ key: slug });
+ muya.destroy();
+ });
+
+ it('arrowDown on the mount frontier navigates into the pending tail (review: frontier arrow)', () => {
+ const muya = bootMuya();
+ expect(mountedParagraphs(muya)).toBe(512);
+
+ // Block 511 is the last MOUNTED block, not the last block: ArrowDown
+ // must reach Paragraph 512, not append an empty paragraph between
+ // the prefix and the pending tail.
+ const frontier = (muya.editor.scrollPage!.find(511) as unknown as {
+ lastContentInDescendant: () => {
+ text: string;
+ setCursor: (start: number, end: number, focus?: boolean) => void;
+ arrowHandler: (event: Event) => void;
+ };
+ }).lastContentInDescendant();
+ expect(frontier.text).toBe('Paragraph 511');
+
+ muya.editor.activeContentBlock = frontier as never;
+ frontier.setCursor(frontier.text.length, frontier.text.length, true);
+ frontier.arrowHandler(
+ new KeyboardEvent('keydown', { key: 'ArrowDown', cancelable: true }),
+ );
+ muya.editor.jsonState.flush();
+
+ // No paragraph was inserted at the frontier...
+ expect(muya.editor.jsonState.rawState).toHaveLength(SECTIONS);
+ // ...and the successor is now mounted with the caret available in it.
+ expect(mountedParagraphs(muya)).toBeGreaterThanOrEqual(513);
+ expect(muya.domNode.textContent).toContain('Paragraph 512');
+ vi.runAllTimers();
+ expectDomMatchesState(muya);
+ muya.destroy();
+ });
+
+ it('a click between blocks or in the gutter never flushes the pending tail (review: eager flush)', () => {
+ const muya = bootMuya();
+ expect(mountedParagraphs(muya)).toBe(512);
+
+ // clientY does not clear the mounted tail's bottom: not an
+ // end-of-document gesture — nothing mounts, nothing is inserted.
+ const click = new MouseEvent('click', { clientY: 0, bubbles: true, cancelable: true });
+ if (!('x' in click))
+ Object.defineProperty(click, 'x', { value: 0 });
+ muya.editor.scrollPage!.domNode!.dispatchEvent(click);
+ muya.editor.jsonState.flush();
+
+ expect(mountedParagraphs(muya)).toBe(512);
+ expect(muya.editor.jsonState.rawState).toHaveLength(SECTIONS);
+ muya.destroy();
+ });
+
+ it('enter in the last table row at the frontier navigates into the pending tail (review: table exit)', () => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const parts = Array.from({ length: SECTIONS }, (_, i) => `Paragraph ${i}`);
+ parts[550] = '| a | b |\n| - | - |\n| c | d |';
+ const muya = new Muya(host, {
+ markdown: `${parts.join('\n\n')}\n`,
+ } as ConstructorParameters[1]);
+ muya.init();
+ bootedHosts.push(muya.domNode);
+
+ // Put the mount frontier exactly at the table.
+ muya.editor.scrollPage!.ensureMountedThrough(550);
+ const before = mountedParagraphs(muya);
+ const cell = (muya.editor.scrollPage!.find(550) as unknown as {
+ lastContentInDescendant: () => {
+ text: string;
+ setCursor: (start: number, end: number, focus?: boolean) => void;
+ enterHandler: (event: Event) => void;
+ };
+ }).lastContentInDescendant();
+ expect(cell.text).toBe('d');
+
+ muya.editor.activeContentBlock = cell as never;
+ cell.setCursor(1, 1, true);
+ cell.enterHandler(new KeyboardEvent('keydown', { key: 'Enter', cancelable: true }));
+ muya.editor.jsonState.flush();
+
+ // No paragraph inserted at the frontier; the successor was mounted.
+ expect(muya.editor.jsonState.rawState).toHaveLength(SECTIONS);
+ expect(mountedParagraphs(muya)).toBeGreaterThan(before);
+ vi.runAllTimers();
+ expectDomMatchesState(muya);
+ muya.destroy();
+ });
+
+ it('arrow-down in the last table row at the frontier navigates into the pending tail (review: table exit)', () => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const parts = Array.from({ length: SECTIONS }, (_, i) => `Paragraph ${i}`);
+ parts[550] = '| a | b |\n| - | - |\n| c | d |';
+ const muya = new Muya(host, {
+ markdown: `${parts.join('\n\n')}\n`,
+ } as ConstructorParameters[1]);
+ muya.init();
+ bootedHosts.push(muya.domNode);
+
+ muya.editor.scrollPage!.ensureMountedThrough(550);
+ const before = mountedParagraphs(muya);
+ const cell = (muya.editor.scrollPage!.find(550) as unknown as {
+ lastContentInDescendant: () => {
+ text: string;
+ setCursor: (start: number, end: number, focus?: boolean) => void;
+ arrowHandler: (event: Event) => void;
+ };
+ }).lastContentInDescendant();
+
+ muya.editor.activeContentBlock = cell as never;
+ cell.setCursor(1, 1, true);
+ cell.arrowHandler(new KeyboardEvent('keydown', { key: 'ArrowDown', cancelable: true }));
+ muya.editor.jsonState.flush();
+
+ expect(muya.editor.jsonState.rawState).toHaveLength(SECTIONS);
+ expect(mountedParagraphs(muya)).toBeGreaterThan(before);
+ vi.runAllTimers();
+ expectDomMatchesState(muya);
+ muya.destroy();
+ });
+
+ it('shift-enter out of a code block at the frontier navigates into the pending tail (review: code exit)', () => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const parts = Array.from({ length: SECTIONS }, (_, i) => `Paragraph ${i}`);
+ parts[550] = '```js\ncode\n```';
+ const muya = new Muya(host, {
+ markdown: `${parts.join('\n\n')}\n`,
+ } as ConstructorParameters[1]);
+ muya.init();
+ bootedHosts.push(muya.domNode);
+
+ muya.editor.scrollPage!.ensureMountedThrough(550);
+ const before = mountedParagraphs(muya);
+ const content = (muya.editor.scrollPage!.find(550) as unknown as {
+ lastContentInDescendant: () => {
+ text: string;
+ setCursor: (start: number, end: number, focus?: boolean) => void;
+ enterHandler: (event: KeyboardEvent) => void;
+ };
+ }).lastContentInDescendant();
+ expect(content.text).toBe('code');
+
+ muya.editor.activeContentBlock = content as never;
+ content.setCursor(4, 4, true);
+ content.enterHandler(
+ new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true, cancelable: true }),
+ );
+ muya.editor.jsonState.flush();
+
+ expect(muya.editor.jsonState.rawState).toHaveLength(SECTIONS);
+ expect(mountedParagraphs(muya)).toBeGreaterThan(before);
+ vi.runAllTimers();
+ expectDomMatchesState(muya);
+ muya.destroy();
+ });
+
+ it('a footnote tap without a tool listener never flushes the pending tail (review: consumer gate)', () => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const parts = Array.from({ length: 599 }, (_, i) => `Paragraph ${i}`);
+ parts[0] = 'Intro with a footnote[^tail] reference.';
+ const markdown = `${parts.join('\n\n')}\n\n[^tail]: The definition lives in the tail.\n`;
+ const muya = new Muya(host, {
+ markdown,
+ footnote: true,
+ } as ConstructorParameters[1]);
+ muya.init();
+ bootedHosts.push(muya.domNode);
+ const before = mountedParagraphs(muya);
+ expect(before).toBeLessThan(599);
+
+ // No `muya-footnote-tool` subscriber: preparing the whole-document
+ // definition map would freeze a large file for nothing.
+ const reference = document.createElement('sup');
+ reference.id = 'noteref-tail';
+ const first = muya.editor.scrollPage!.firstContentInDescendant()!;
+ (first as unknown as {
+ _emitFootnoteToolEvent: (el: HTMLElement) => void;
+ })._emitFootnoteToolEvent(reference);
+
+ expect(mountedParagraphs(muya)).toBe(before);
+ muya.destroy();
+ });
+
+ it('arrow-down across an ALREADY-MOUNTED empty container still reaches the tail (review: frontier start)', () => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const muya = new Muya(host, { markdown: 'seed\n' } as ConstructorParameters[1]);
+ muya.init();
+ bootedHosts.push(muya.domNode);
+
+ // 511 paragraphs + an empty container fill the synchronous prefix
+ // EXACTLY (512 weight), so the empty container sits at the frontier
+ // already mounted — a resolver that starts mounting at the
+ // receiver's index + 1 finds that index mounted, makes no progress,
+ // and misreads the pending tail as the document end.
+ const states = [
+ ...Array.from({ length: 511 }, (_, i) => ({
+ name: 'paragraph',
+ text: `Paragraph ${i}`,
+ })),
+ { name: 'block-quote', children: [] },
+ { name: 'paragraph', text: 'Paragraph after empty container' },
+ ];
+ muya.setContent(states as never);
+ expect(mountedParagraphs(muya)).toBe(511);
+ expect(
+ (muya.editor.scrollPage!.find(511) as unknown as { blockName: string }).blockName,
+ ).toBe('block-quote');
+
+ const frontier = (muya.editor.scrollPage!.find(510) as unknown as {
+ lastContentInDescendant: () => {
+ text: string;
+ setCursor: (start: number, end: number, focus?: boolean) => void;
+ arrowHandler: (event: Event) => void;
+ };
+ }).lastContentInDescendant();
+ expect(frontier.text).toBe('Paragraph 510');
+
+ muya.editor.activeContentBlock = frontier as never;
+ frontier.setCursor(0, 0, true);
+ frontier.arrowHandler(
+ new KeyboardEvent('keydown', { key: 'ArrowDown', cancelable: true }),
+ );
+ muya.editor.jsonState.flush();
+
+ expect(muya.editor.jsonState.rawState).toHaveLength(513);
+ expect(muya.domNode.textContent).toContain('Paragraph after empty container');
+ vi.runAllTimers();
+ expectDomMatchesState(muya);
+ muya.destroy();
+ });
+
+ it('delete at the end of the frontier paragraph merges with the pending successor (review: delete merge)', () => {
+ const muya = bootMuya();
+ expect(mountedParagraphs(muya)).toBe(512);
+
+ const frontier = (muya.editor.scrollPage!.find(511) as unknown as {
+ lastContentInDescendant: () => {
+ text: string;
+ setCursor: (start: number, end: number, focus?: boolean) => void;
+ deleteHandler: (event: KeyboardEvent) => void;
+ };
+ }).lastContentInDescendant();
+ expect(frontier.text).toBe('Paragraph 511');
+
+ muya.editor.activeContentBlock = frontier as never;
+ frontier.setCursor(frontier.text.length, frontier.text.length, true);
+ frontier.deleteHandler(
+ new KeyboardEvent('keydown', { key: 'Delete', cancelable: true }),
+ );
+ muya.editor.jsonState.flush();
+
+ // Same as at full mount: the two paragraphs merge — the keystroke is
+ // not swallowed by the mount frontier.
+ expect(muya.editor.jsonState.rawState).toHaveLength(SECTIONS - 1);
+ expect(frontier.text).toBe('Paragraph 511Paragraph 512');
+ vi.runAllTimers();
+ expectDomMatchesState(muya);
+ muya.destroy();
+ });
+
+ it('tab out of the last table cell at the frontier reaches the pending successor (review: table tab)', () => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const parts = Array.from({ length: SECTIONS }, (_, i) => `Paragraph ${i}`);
+ parts[550] = '| a | b |\n| - | - |\n| c | d |';
+ const muya = new Muya(host, {
+ markdown: `${parts.join('\n\n')}\n`,
+ } as ConstructorParameters[1]);
+ muya.init();
+ bootedHosts.push(muya.domNode);
+
+ muya.editor.scrollPage!.ensureMountedThrough(550);
+ const before = mountedParagraphs(muya);
+ const cell = (muya.editor.scrollPage!.find(550) as unknown as {
+ lastContentInDescendant: () => {
+ text: string;
+ setCursor: (start: number, end: number, focus?: boolean) => void;
+ tabHandler: (event: Event) => void;
+ };
+ }).lastContentInDescendant();
+ expect(cell.text).toBe('d');
+
+ muya.editor.activeContentBlock = cell as never;
+ cell.setCursor(1, 1, true);
+ cell.tabHandler(new KeyboardEvent('keydown', { key: 'Tab', cancelable: true }));
+ muya.editor.jsonState.flush();
+
+ expect(muya.editor.jsonState.rawState).toHaveLength(SECTIONS);
+ expect(mountedParagraphs(muya)).toBeGreaterThan(before);
+ expect(muya.domNode.textContent).toContain('Paragraph 551');
+ muya.destroy();
+ });
+
+ it('destroy cancels a pending mount so timers never fire afterwards', () => {
+ const muya = bootMuya();
+ expect(mountedParagraphs(muya)).toBe(512);
+
+ muya.destroy();
+ expect(() => vi.runAllTimers()).not.toThrow();
+ });
+});
diff --git a/third_party/muya/src/block/scrollPage/index.ts b/third_party/muya/src/block/scrollPage/index.ts
index 82d0b5a..dc05569 100644
--- a/third_party/muya/src/block/scrollPage/index.ts
+++ b/third_party/muya/src/block/scrollPage/index.ts
@@ -1,3 +1,4 @@
+import type { JSONOpList } from 'ot-json1';
import type { Muya } from '../../muya';
import type { TState } from '../../state/types';
import type { Nullable } from '../../types';
@@ -5,6 +6,7 @@ import type Content from '../base/content';
import type TreeNode from '../base/treeNode';
import type { IConstructor, TBlockPath } from '../types';
import { BLOCK_DOM_PROPERTY } from '../../config';
+import { cloneStateTree } from '../../state/cloneState';
import { isHTMLElement, isMouseEvent } from '../../utils';
import logger from '../../utils/logger';
import Parent from '../base/parent';
@@ -16,6 +18,31 @@ interface IBlurFocus {
focus: Nullable;
}
+// Recursive node count of a state subtree — the unit of mount budgeting.
+function stateWeight(state: TState): number {
+ let weight = 1;
+ if ('children' in state && Array.isArray(state.children)) {
+ for (const child of state.children as TState[])
+ weight += stateWeight(child);
+ }
+ return weight;
+}
+
+// End index (exclusive) after taking blocks from `from` until `budget`
+// weight is consumed. Always takes at least one block so progress is made
+// even when a single block exceeds the whole budget.
+function takeByWeight(state: TState[], from: number, budget: number): number {
+ let end = from;
+ let used = 0;
+ while (end < state.length) {
+ used += stateWeight(state[end]);
+ end += 1;
+ if (used >= budget)
+ break;
+ }
+ return end;
+}
+
export class ScrollPage extends Parent {
private _blurFocus: IBlurFocus = { blur: null, focus: null };
@@ -51,17 +78,35 @@ export class ScrollPage extends Parent {
static create(muya: Muya, state: TState[]) {
const scrollPage = new ScrollPage(muya);
- scrollPage.append(
- ...state.map((block) => {
- return this.loadBlock(block.name).create(muya, block);
- }),
- );
+ scrollPage._mountBlocks(state);
scrollPage.parent!.domNode!.appendChild(scrollPage.domNode!);
return scrollPage;
}
+ // Chunked-mount tuning. The synchronous prefix is budgeted in STATE
+ // WEIGHT (the recursive node count of a top-level block, not block
+ // count — one list with thousands of items would blow a count budget)
+ // and comfortably overfills any viewport. Background chunks are budgeted
+ // by ELAPSED TIME so a scheduled task stays under half a frame on the
+ // machine actually running it. A single top-level block larger than
+ // either budget still mounts as one unit — blocks are atomic — which is
+ // the documented bound of this scheme.
+ // The chunk budget bounds the JS append work only — the browser still
+ // styles/lays out each appended chunk after the task, so the observed
+ // event-loop gap is larger than the budget. 4 ms keeps that total gap
+ // small on typical content; the perf smoke guards it end to end.
+ private static readonly _initialMountWeight = 512;
+ private static readonly _chunkBudgetMs = 4;
+
+ private _pendingMount: {
+ handle: ReturnType | null;
+ } | null = null;
+
+ // See suspendOnDemandMount().
+ private _onDemandMountSuspended = false;
+
override get path() {
return [];
}
@@ -94,16 +139,226 @@ export class ScrollPage extends Parent {
}
updateState(state: TState[]) {
- const { muya } = this;
+ this.cancelPendingMount();
// Empty scrollPage dom
this.empty();
+ this._mountBlocks(state);
+ }
+
+ // Building every block's DOM subtree up front is the dominant open cost on
+ // large documents (#4887): parse and state are linear, but a 300k-word file
+ // still means ~100k block nodes constructed in one synchronous task. Mount
+ // an over-viewport prefix synchronously and append the rest in scheduled
+ // time-budgeted chunks, so the document is visible and editable at once.
+ //
+ // The pending tail is NOT a snapshot. Every chunk re-reads
+ // `jsonState.rawState` and resumes at the live child count; the invariant
+ // is that the mounted prefix always equals `state[0 .. children.length)`.
+ // Structural edits (Enter splits, paste, block removal) mutate the live
+ // tree and the state at the SAME index, so the invariant survives them
+ // with no flushing. Whole-tree consumers (`queryBlock` into the tail,
+ // `lastContentInDescendant`, search) mount what they need through
+ // `ensureMountedThrough` / `flushPendingMount`.
+ private _mountBlocks(state: TState[]) {
+ const initial = takeByWeight(state, 0, ScrollPage._initialMountWeight);
+ this._appendFromState(state, 0, initial);
+
+ if (state.length > initial) {
+ this._pendingMount = { handle: null };
+ this._scheduleNextChunk();
+ }
+ }
+
+ private _appendFromState(state: readonly TState[], from: number, to: number) {
+ // Blocks keep references into the state object they are built from
+ // (`this.meta = state.meta` and the like). The slice may come straight
+ // from `jsonState.rawState`, so clone it — a block mutating shared
+ // metadata would silently rewrite the JSON state ahead of its op.
this.append(
- ...state.map((block) => {
- return ScrollPage.loadBlock(block.name).create(muya, block);
+ ...cloneStateTree(state.slice(from, to)).map((block) => {
+ return ScrollPage.loadBlock(block.name).create(this.muya, block);
}),
);
}
+ private _scheduleNextChunk() {
+ if (!this._pendingMount)
+ return;
+
+ // setTimeout keeps mounting while the tab is backgrounded (rAF stalls).
+ this._pendingMount.handle = setTimeout(() => {
+ this._mountNextChunk();
+ }, 0);
+ }
+
+ private _mountNextChunk() {
+ const pending = this._pendingMount;
+ if (!pending)
+ return;
+
+ // Edits mutate the live tree immediately but their ops reach the
+ // state on the next frame; drain them first so the live child count
+ // is a valid cursor into `rawState`.
+ this.muya.editor.jsonState.flush();
+
+ // Mount block by block until the task budget is spent — actual
+ // elapsed time, not a fixed weight, so one chunk never grows into a
+ // frame-blowing task on heavy content.
+ const states = this.muya.editor.jsonState.rawState;
+ const started = performance.now();
+ let next = this.children.length;
+ while (
+ next < states.length
+ && performance.now() - started < ScrollPage._chunkBudgetMs
+ ) {
+ this._appendFromState(states, next, next + 1);
+ next += 1;
+ }
+
+ if (next >= states.length) {
+ this._pendingMount = null;
+ this.muya.eventCenter.emit('muya-mount-complete', { total: states.length });
+ }
+ else {
+ this.muya.eventCenter.emit('muya-mount-progress', {
+ mounted: next,
+ total: states.length,
+ });
+ this._scheduleNextChunk();
+ }
+ }
+
+ /**
+ * Mount every top-level block an ot-json1 operation touches. MUST run
+ * against the pre-dispatch state: applying the op to the state first and
+ * mounting afterwards would materialize the target from the updated
+ * state and then apply the op to it a second time.
+ */
+ ensureMountedForOperation(op: JSONOpList) {
+ if (!this._pendingMount)
+ return;
+
+ // Collect the leading numeric descent of every path in the op —
+ // the top-level block indices. Deeper numbers (child indices, text
+ // offsets) are never the FIRST element of a component, and branch
+ // lists nest their sub-paths as arrays.
+ let max = -1;
+ const scan = (component: unknown) => {
+ if (!Array.isArray(component) || component.length === 0)
+ return;
+ const first: unknown = component[0];
+ if (typeof first === 'number') {
+ if (first > max)
+ max = first;
+ }
+ else if (Array.isArray(first)) {
+ for (const child of component)
+ scan(child);
+ }
+ };
+ scan(op);
+
+ // Mount one PAST the deepest touched index: a replace picks the
+ // target out of the live tree and then drops the replacement before
+ // the next sibling — that reference sibling must already be mounted,
+ // or the drop would materialize it from the post-dispatch state.
+ if (max >= 0)
+ this.ensureMountedThrough(max + 1);
+ }
+
+ /**
+ * While the incremental pick/drop walker applies an operation, on-demand
+ * materialization must stay OFF: the state is already post-dispatch, so
+ * mounting during the walk would materialize nodes the walker itself is
+ * about to insert (duplicating them). Targets are pre-mounted from the
+ * pre-dispatch state instead (`ensureMountedForOperation`); if the
+ * walker still reaches an unmounted path it fails loudly and the
+ * caller's fallback rebuilds the whole tree.
+ */
+ suspendOnDemandMount() {
+ this._onDemandMountSuspended = true;
+ }
+
+ resumeOnDemandMount() {
+ this._onDemandMountSuspended = false;
+ }
+
+ /**
+ * Synchronously mount blocks until the top-level index is available.
+ * Bounded consumers (offset-based cursor restore, path queries into the
+ * tail, TOC click-to-scroll) use this instead of a full flush.
+ */
+ ensureMountedThrough(index: number) {
+ const pending = this._pendingMount;
+ if (!pending || index < this.children.length)
+ return;
+
+ // See _mountNextChunk: pending edit ops must land in the state
+ // before the live child count is used as a cursor into it.
+ this.muya.editor.jsonState.flush();
+
+ const states = this.muya.editor.jsonState.rawState;
+ if (index >= states.length - 1) {
+ this.flushPendingMount();
+ return;
+ }
+
+ if (pending.handle !== null) {
+ clearTimeout(pending.handle);
+ pending.handle = null;
+ }
+ this._appendFromState(states, this.children.length, index + 1);
+ this.muya.eventCenter.emit('muya-mount-progress', {
+ mounted: this.children.length,
+ total: states.length,
+ });
+ this._scheduleNextChunk();
+ }
+
+ override lastContentInDescendant() {
+ // End-of-document consumers (Ctrl+End, select-all, cursor restore)
+ // must see the real last block, not the last mounted chunk.
+ this.flushPendingMount();
+
+ return super.lastContentInDescendant();
+ }
+
+ /** Synchronously finish a chunked mount that is still in flight. */
+ flushPendingMount() {
+ const pending = this._pendingMount;
+ if (!pending)
+ return;
+
+ if (pending.handle !== null)
+ clearTimeout(pending.handle);
+ this._pendingMount = null;
+
+ // See _mountNextChunk: pending edit ops must land in the state
+ // before the live child count is used as a cursor into it.
+ this.muya.editor.jsonState.flush();
+
+ const states = this.muya.editor.jsonState.rawState;
+ this._appendFromState(states, this.children.length, states.length);
+ this.muya.eventCenter.emit('muya-mount-complete', {
+ total: states.length,
+ });
+ }
+
+ /**
+ * Drop a mount that is still in flight without materializing it. Called
+ * by `updateState` before a rebuild and by `Muya.destroy` — the pending
+ * timer must never run against a torn-down instance. An explicit call is
+ * the lifecycle signal; DOM connectedness is deliberately not consulted,
+ * so detached hosts keep mounting (`new Muya(detachedElement)` works).
+ */
+ cancelPendingMount() {
+ if (!this._pendingMount)
+ return;
+ if (this._pendingMount.handle !== null)
+ clearTimeout(this._pendingMount.handle);
+ this._pendingMount = null;
+ }
+
/**
* Find the content block by the path
* @param {Array} path
@@ -112,6 +367,19 @@ export class ScrollPage extends Parent {
if (path.length === 0)
return this;
+ // An op or lookup may target a block whose chunk has not mounted yet —
+ // mount up to the target only, never the whole remainder (a full
+ // flush here would turn the source-mode cursor handoff back into a
+ // whole-document mount). Suspended while the pick/drop walker runs:
+ // see suspendOnDemandMount().
+ if (
+ this._pendingMount
+ && !this._onDemandMountSuspended
+ && (path[0] as number) >= this.children.length
+ ) {
+ this.ensureMountedThrough(path[0] as number);
+ }
+
const p = path.shift() as number;
const block = this.find(p) as Parent & { queryBlock: (p: TBlockPath) => Parent | Content | undefined };
return block && path.length ? block.queryBlock(path) : block;
@@ -187,6 +455,17 @@ export class ScrollPage extends Parent {
const target = event.target;
if (target[BLOCK_DOM_PROPERTY] === this) {
+ // While a tail is pending, the area below the mount frontier is
+ // NOT blank — the document logically continues there (#4887).
+ // Appending would land mid-document; materializing the tail on a
+ // container click would freeze a large file for taps in the
+ // gutter or between blocks, and the forced reflow then moves the
+ // real bottom below the click point anyway. Do nothing —
+ // background chunks finish shortly and end-of-document clicks
+ // behave normally again.
+ if (this._pendingMount)
+ return;
+
const lastChild = this.lastChild as Parent;
const lastContentBlock = lastChild.lastContentInDescendant()!;
const { clientY } = event;
diff --git a/third_party/muya/src/editor/index.ts b/third_party/muya/src/editor/index.ts
index 60e046b..99c6960 100644
--- a/third_party/muya/src/editor/index.ts
+++ b/third_party/muya/src/editor/index.ts
@@ -398,6 +398,13 @@ export class Editor {
updateContents(operations: JSONOp, selection: Nullable, source: string) {
const muya = this._muya;
+ // Mount every top-level block the operation touches BEFORE it reaches
+ // the state: the DOM pick/drop below resolves blocks via queryBlock,
+ // which would otherwise materialize an unmounted target from the
+ // ALREADY-updated state and then drop the same operation onto it a
+ // second time — a permanent state/DOM fork (#4887 review).
+ if (operations !== null)
+ this.scrollPage?.ensureMountedForOperation(operations);
// ot-json1 no-op (`null`) is forwarded to dispatch — JSONState
// short-circuits internally so listeners still see a json-change
// event for the no-op.
@@ -408,13 +415,20 @@ export class Editor {
return;
try {
+ // On-demand mounting must not run inside the walk: the state is
+ // post-dispatch here, so a mount would materialize nodes the
+ // walker itself is about to drop (duplicates). Targets were
+ // pre-mounted above; a miss below fails into the rebuild path.
+ this.scrollPage?.suspendOnDemandMount();
const snapshot = pick(this.scrollPage as BlockNode, operations);
drop(snapshot, operations, muya);
+ this.scrollPage?.resumeOnDemandMount();
this._restoreSelection(selection);
}
catch (error) {
+ this.scrollPage?.resumeOnDemandMount();
// The incremental walk left the live tree half-applied (pick removed
// blocks drop never re-inserted). The json state is authoritative and
// already up to date — rebuild from it instead of leaving an empty doc.
@@ -498,6 +512,13 @@ export class Editor {
const state = this.jsonState.getState();
this.scrollPage!.updateState(state);
+ // The old document's selection must not leak into the new one:
+ // focus() re-resolves the stale anchorPath against the fresh tree,
+ // and during a progressive mount that path query would force-mount
+ // up to (or past) the old caret (#4887 review). Callers restore
+ // carets through their own flows afterwards.
+ this.selection.clear();
+ this.activeContentBlock = null;
this.history.clear();
this.searchModule.reset();
diff --git a/third_party/muya/src/event/index.ts b/third_party/muya/src/event/index.ts
index 6f26560..38261d4 100644
--- a/third_party/muya/src/event/index.ts
+++ b/third_party/muya/src/event/index.ts
@@ -85,6 +85,16 @@ class EventCenter {
this.listeners[event] = [handler];
}
+ /**
+ * Whether any listener is subscribed to `event`. Lets producers skip
+ * expensive preparation (e.g. completing a progressive mount, #4887)
+ * when no consumer is installed.
+ */
+ hasListeners(event: string): boolean {
+ const listeners = this.listeners[event];
+ return Array.isArray(listeners) && listeners.length > 0;
+ }
+
/**
* [on] on custom event
*/
diff --git a/third_party/muya/src/history/index.ts b/third_party/muya/src/history/index.ts
index cbb8bf9..778ec8b 100644
--- a/third_party/muya/src/history/index.ts
+++ b/third_party/muya/src/history/index.ts
@@ -149,6 +149,18 @@ class History {
}
private _change(source: HistoryAction, dest: HistoryAction) {
+ // A same-frame edit may still sit in the rAF op batch. Drain it into
+ // the history FIRST — before the stack check AND before
+ // `_ignoreChange` silences json-change:
+ // - recording the pending edit clears the redo stack, so checking
+ // length before flushing would let redo() pop an emptied stack;
+ // - anything mid-undo that flushes the batch (e.g. an on-demand
+ // mount during a progressive mount, #4887) would otherwise apply
+ // the edit while dropping its history entry;
+ // - and an edit that has not flushed yet becomes undoable
+ // immediately, even when the stack was empty before it.
+ this._muya.editor.jsonState.flush();
+
if (this._stack[source].length === 0)
return;
diff --git a/third_party/muya/src/muya.ts b/third_party/muya/src/muya.ts
index bb72cf9..aebe1cc 100644
--- a/third_party/muya/src/muya.ts
+++ b/third_party/muya/src/muya.ts
@@ -221,6 +221,15 @@ export class Muya {
return this.editor.jsonState.getTOC();
}
+ /**
+ * Synchronously mount top-level blocks up to `index` if a progressive
+ * mount is still in flight (#4887). Hosts call this before scrolling to
+ * a block whose DOM may not exist yet — e.g. a TOC entry's `index`.
+ */
+ ensureMountedThrough(index: number) {
+ this.editor.scrollPage?.ensureMountedThrough(index);
+ }
+
undo() {
this.editor.history.undo();
}
@@ -1637,6 +1646,9 @@ export class Muya {
}
destroy() {
+ // A chunked mount still in flight must never fire against a
+ // torn-down instance (scrollPage/index.ts).
+ this.editor.scrollPage?.cancelPendingMount();
this.eventCenter.detachAllDomEvents();
this.eventCenter.unsubscribeAll();
// this.domNode[BLOCK_DOM_PROPERTY] = null;
diff --git a/third_party/muya/src/search/index.ts b/third_party/muya/src/search/index.ts
index 80282d3..dcbfe71 100644
--- a/third_party/muya/src/search/index.ts
+++ b/third_party/muya/src/search/index.ts
@@ -210,6 +210,11 @@ export class Search {
// Highlight current search.
if (value) {
+ // Search walks the LIVE tree; a progressive mount still in flight
+ // would silently drop tail matches (#4887). Searching is an
+ // explicit user action, so completing the mount here is the
+ // documented trade.
+ this._scrollPage?.flushPendingMount();
this._scrollPage?.depthFirstTraverse((block: TreeNode) => {
if (block.isContent()) {
const { text } = block;
diff --git a/third_party/muya/src/selection/offsetCursor.ts b/third_party/muya/src/selection/offsetCursor.ts
index b75e819..f85a58b 100644
--- a/third_party/muya/src/selection/offsetCursor.ts
+++ b/third_party/muya/src/selection/offsetCursor.ts
@@ -127,6 +127,18 @@ function _findSentinel(scrollPage: ScrollPage, sentinel: string): ISentinelHit |
* the anchor sentinel precedes it in the same block.
*/
export function resolveSentinelCursor(scrollPage: ScrollPage): IPathCursor | null {
+ // The sentinel may sit in a block whose mount chunk has not run yet
+ // (#4887 progressive mount). Locate its top-level index in the COMPLETE
+ // state and mount just far enough — never the whole remainder.
+ const states = scrollPage.muya.editor.jsonState.rawState;
+ for (let i = states.length - 1; i >= 0; i--) {
+ const raw = JSON.stringify(states[i]);
+ if (raw.includes(ANCHOR_SENTINEL) || raw.includes(FOCUS_SENTINEL)) {
+ scrollPage.ensureMountedThrough(i);
+ break;
+ }
+ }
+
const anchorHit = _findSentinel(scrollPage, ANCHOR_SENTINEL);
const focusHit = _findSentinel(scrollPage, FOCUS_SENTINEL);
diff --git a/third_party/muya/src/state/getTOC.ts b/third_party/muya/src/state/getTOC.ts
index a8cf391..41e8961 100644
--- a/third_party/muya/src/state/getTOC.ts
+++ b/third_party/muya/src/state/getTOC.ts
@@ -1,49 +1,50 @@
-import type Content from '../block/base/content';
-import type Parent from '../block/base/parent';
import type { Muya } from '../muya';
+import type { TState } from './types';
import { tokenizer, tokensToPlainText } from '../inlineRenderer/lexer';
import { getUniqueId } from '../utils';
import { generateGithubSlug } from '../utils/slug';
+import { isAtxHeadingState, isSetextHeadingState } from './types';
export interface ITocItem {
content: string;
lvl: number;
slug: string;
githubSlug: string;
+ // Top-level state index of the heading. During a progressive mount the
+ // heading's DOM may not exist yet; hosts pass this to
+ // `muya.ensureMountedThrough(index)` before scrolling to it.
+ index: number;
}
-interface IHeadingBlock extends Parent {
- meta: { level: number };
-}
-
-const slugCache = new WeakMap();
+// Slugs are keyed on the heading's STATE node, not its block: the TOC is
+// collected from `jsonState` (the complete logical document) so it stays
+// correct even while block mounting is in progress, and block-side consumers
+// (headingCopyLink) resolve their heading to the same state node. ot-json1
+// apply is copy-on-write, so an unedited heading keeps its node — and its
+// slug — across other edits; editing the heading replaces the node, which
+// only re-keys that entry (hosts rebuild their TOC on `json-change` anyway).
+const slugCache = new WeakMap