feat(muya): serialize/restore undo history by Jocs · Pull Request #4394 · marktext/marktext · GitHub
Skip to content

feat(muya): serialize/restore undo history - #4394

Merged
Jocs merged 2 commits into
developfrom
feat/muya-history-serialization
Jun 8, 2026
Merged

Jocs merged 2 commits into
developfrom
feat/muya-history-serialization

Conversation

@Jocs

@Jocs Jocs commented Jun 8, 2026

Copy link
Copy Markdown
Member

Why

The desktop shell preserves each open file's undo/redo history across tab switches: it reads the editor's history, stores it on the tab, and restores it when the tab is re-selected. @muyajs/core's History had no public serialize/restore API, so this couldn't work during the desktop migration off legacy packages/muyajs.

What

Adds a JSON-serializable view of the History undo/redo stacks plus the three delegating methods on Muya.

History

  • getHistory() — returns a deep, JSON-serializable snapshot of the undo/redo stacks plus the lastRecorded / selectionStack recording bookkeeping.
  • setHistory(history) — restores a snapshot. Round-trip is lossless: setHistory(getHistory()) then undo() reproduces the prior document states.
  • clear() — now also resets selectionStack / lastRecorded (previously only the stacks).

Muya (src/muya.ts)

  • getHistory() / setHistory() / clearHistory() delegating to editor.history, inserted immediately after redo().

Serialization approach

ot-json1 ops are plain JSON arrays and are deep-cloned (structuredClone) as-is.

The recorded selection (ISelection) is the only non-serializable part: it carries live anchorBlock / focusBlock ContentBlock references. The snapshot drops those block references and keeps only the already-serializable anchorPath / focusPath arrays plus the anchor / focus offsets and flags.

This is lossless because the live blocks are an in-memory optimization only — every consumer of a restored selection re-resolves the target block from the path:

  • editor.updateContents resolves the cursor block via scrollPage.queryBlock(anchorPath).
  • selection._setCursor falls back to scrollPage.queryBlock(anchorPath / focusPath) whenever no block instance is present.

So a path-only restored selection restores the caret exactly. No selection-restore limitation.

Tests

New happy-dom vitest spec (src/__tests__/historySerialization.spec.ts) following the getTOC.spec.ts boot pattern (edits flushed on rAF via vi.waitFor):

  • getHistory() is JSON-round-trippable (JSON.parse(JSON.stringify(...)) equals the snapshot) and the recorded selection carries no live block fields.
  • setHistory(getHistory()) after dropping the in-memory history, then undoing twice, walks the document back through both edits to its original state — proving lossless reproduction of prior states.
  • restored history round-trips back through redo() to the snapshot document (persisted through JSON.stringify exactly as the desktop would).
  • clearHistory() empties both stacks.

Verify

All green in packages/muya:

  • lint — 0 errors (26 pre-existing warnings, unchanged from develop)
  • lint:types — clean
  • check-circular — no circular dependency
  • test — 439 passed (4 new)
  • test:spec — 1347 passed (CommonMark + GFM conformance)

🤖 Generated with Claude Code

Add a JSON-serializable view of the History undo/redo stacks so the
desktop shell can persist each tab's editing history across tab switches.

- History.getHistory() returns a deep, JSON-serializable snapshot of the
  undo/redo stacks plus the lastRecorded / selectionStack bookkeeping.
  ot-json1 ops are plain JSON arrays (deep-cloned as-is). The stored
  selection carries live anchorBlock/focusBlock references, so the
  snapshot strips them and keeps only the serializable anchorPath /
  focusPath + offsets.
- History.setHistory() restores the snapshot. The restored, path-only
  selections re-resolve their target block from the path on apply:
  editor.updateContents and selection._setCursor both fall back to
  scrollPage.queryBlock(path) when no block instance is present, so the
  caret restores losslessly.
- History.clear() now also resets selectionStack / lastRecorded.
- Muya exposes getHistory() / setHistory() / clearHistory() delegating to
  editor.history, placed immediately after redo().

Adds a happy-dom vitest spec covering the JSON round-trip, the
setHistory(getHistory()) + undo() lossless reproduction of prior states,
the redo() round-trip, and clearHistory().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 8, 2026 06:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR adds a public, JSON-serializable undo/redo history API to @muyajs/core so the desktop shell can persist and restore per-tab editing history across tab switches during the migration off legacy packages/muyajs.

Changes:

  • Added History.getHistory() / History.setHistory() to snapshot and restore undo/redo stacks (including selection bookkeeping) in a JSON-safe shape.
  • Updated History.clear() to also reset selection bookkeeping (selectionStack, lastRecorded) in addition to clearing stacks.
  • Exposed Muya.getHistory() / Muya.setHistory() / Muya.clearHistory() as delegating methods and added new Vitest coverage for round-trip behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
packages/muya/src/muya.ts Adds public Muya methods delegating history snapshot/restore/clear to the editor history.
packages/muya/src/history/index.ts Implements serialized history snapshot/restore, including selection serialization strategy and updated clear behavior.
packages/muya/src/tests/historySerialization.spec.ts Adds happy-dom Vitest coverage for JSON round-tripping and undo/redo correctness after restore.

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

Comment thread packages/muya/src/history/index.ts Outdated
Comment on lines +237 to +238

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 9733d45. Introduced a dedicated IHistorySelection type in selection/types.ts — it is ISelection with anchorBlock/focusBlock made optional, modeling exactly the fact that a restored history selection may have lost its live block instances.

The history stacks (IOperation.selection, _selectionStack), the serialize/restore helpers, and Editor.updateContents now all use Nullable<IHistorySelection>. _fromSerializableSelection returns the path-only object directly with no cast, since the missing block fields are now part of the contract. A full ISelection is structurally assignable to IHistorySelection, so live selections captured via getSelection() still flow into the stacks without any cast either. The as unknown as ISelection double-cast (and its no-restricted-syntax eslint-disable) are gone.

Runtime behavior is unchanged: updateContents resolves the target block from anchorPath/focusPath via scrollPage.queryBlock and setSelection (which accepts ICursor with optional block fields). Verified: lint (0 errors), lint:types, check-circular (no cycles), test (439 passed, incl. the history round-trip spec), and test:spec (1347 passed) all green.

`History._fromSerializableSelection` rebuilt a path-only selection (no live
`anchorBlock` / `focusBlock`) and cast it `as unknown as ISelection`, where
those block fields are required — an unsound double-cast. The restored
selection is consumed by `Editor.updateContents`, which re-resolves the
target block from `anchorPath` / `focusPath` via `scrollPage.queryBlock`, so
the block instances are genuinely optional at restore time.

Introduce a dedicated `IHistorySelection` type (`ISelection` with optional
`anchorBlock` / `focusBlock`) in `selection/types.ts` and use it for the
history stacks (`IOperation.selection`, `_selectionStack`), the serialize /
restore helpers, and `Editor.updateContents`. A full `ISelection` is
assignable to it, so live selections from `getSelection()` still fit without
a cast, and the restored path-only object now type-checks directly — no
`as unknown as X` needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Jocs
Jocs merged commit 8683044 into develop Jun 8, 2026
9 checks passed
@Jocs
Jocs deleted the feat/muya-history-serialization branch June 8, 2026 07:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants