Repository navigation
feat(muya): serialize/restore undo history - #4394
Conversation
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>
There was a problem hiding this comment.
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.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
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>

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'sHistoryhad no public serialize/restore API, so this couldn't work during the desktop migration off legacypackages/muyajs.What
Adds a JSON-serializable view of the
Historyundo/redo stacks plus the three delegating methods onMuya.HistorygetHistory()— returns a deep, JSON-serializable snapshot of the undo/redo stacks plus thelastRecorded/selectionStackrecording bookkeeping.setHistory(history)— restores a snapshot. Round-trip is lossless:setHistory(getHistory())thenundo()reproduces the prior document states.clear()— now also resetsselectionStack/lastRecorded(previously only the stacks).Muya(src/muya.ts)getHistory()/setHistory()/clearHistory()delegating toeditor.history, inserted immediately afterredo().Serialization approach
ot-json1ops are plain JSON arrays and are deep-cloned (structuredClone) as-is.The recorded selection (
ISelection) is the only non-serializable part: it carries liveanchorBlock/focusBlockContentBlockreferences. The snapshot drops those block references and keeps only the already-serializableanchorPath/focusPatharrays plus theanchor/focusoffsets 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.updateContentsresolves the cursor block viascrollPage.queryBlock(anchorPath).selection._setCursorfalls back toscrollPage.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 thegetTOC.spec.tsboot pattern (edits flushed on rAF viavi.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.redo()to the snapshot document (persisted throughJSON.stringifyexactly as the desktop would).clearHistory()empties both stacks.Verify
All green in
packages/muya:lint— 0 errors (26 pre-existing warnings, unchanged fromdevelop)lint:types— cleancheck-circular— no circular dependencytest— 439 passed (4 new)test:spec— 1347 passed (CommonMark + GFM conformance)🤖 Generated with Claude Code