Replace snapshot-based undo/redo with Yorkie-native history in Docs#162
Conversation
Upgrade @yorkie-js/sdk and @yorkie-js/react from 0.7.6 to 0.7.7 which ships splitLevel>=2 undo/redo support. Add `useYorkieUndo` option to YorkieDocStore so callers can opt into Yorkie's native doc.history API instead of the snapshot-based approach. When enabled, snapshot() is a no-op and undo/redo/canUndo/canRedo delegate to doc.history. A try/catch fallback disables the flag and falls back to snapshots if Yorkie history throws. Includes tests for insertText, applyStyle, splitBlock, multiple undo/redo, and canUndo/canRedo via Yorkie history. mergeBlock undo is skipped pending SDK support for editByPath merge reversal. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
📝 WalkthroughWalkthroughThis update advances the Yorkie JS SDK from version 0.7.6 to 0.7.7, adds Yorkie-native undo/redo support to YorkieDocStore with fallback to snapshot-based history, and updates task documentation to reflect completed phases and remaining work for intent-preserving operations. Changes
Sequence Diagram(s)sequenceDiagram
participant App as Application
participant YorkieDocStore as YorkieDocStore<br/>(useYorkieUndo: true)
participant YorkieHistory as Yorkie<br/>doc.history
participant SnapshotStack as Snapshot<br/>Stack (Fallback)
App->>YorkieDocStore: undo()
activate YorkieDocStore
YorkieDocStore->>YorkieHistory: doc.history.undo()
activate YorkieHistory
YorkieHistory-->>YorkieDocStore: success / error
deactivate YorkieHistory
alt History call succeeds
YorkieDocStore->>YorkieDocStore: Mark store dirty<br/>Clear cached doc
YorkieDocStore-->>App: Undo applied
else History call fails
YorkieDocStore->>YorkieDocStore: Log warning<br/>Disable useYorkieUndo
YorkieDocStore->>SnapshotStack: Fallback to snapshot undo
activate SnapshotStack
SnapshotStack-->>YorkieDocStore: Restore prior snapshot
deactivate SnapshotStack
YorkieDocStore-->>App: Fallback undo applied
end
deactivate YorkieDocStore
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
When Yorkie history.undo() throws, return early instead of falling through to snapshot-based path which has an empty stack. Add test that simulates history failure, verifies graceful degradation, and confirms snapshot-based undo works for subsequent operations. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Verification: verify:selfResult: ✅ PASS in 122.0s
Verification: verify:integrationResult: ✅ PASS |
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/frontend/src/app/docs/yorkie-doc-store.ts (1)
1927-1948:⚠️ Potential issue | 🔴 CriticalFallback path crashes when Yorkie history throws —
undoStackis always empty underuseYorkieUndo.When
useYorkieUndois enabled,snapshot()is a no-op (line 1919), sothis.undoStackstays empty for the lifetime of the store. Ifdoc.history.undo()throws, the catch block flips the flag and falls through to the snapshot path without a guard:
- Line 1944:
const previous = this.undoStack.pop()!→ returnsundefined(the!only silences TS).- Line 1945:
writeFullDocument(previous)→ crashes ondocument.header/document.blocksaccess (line 2002, 2009).The same bug is mirrored in
redo()at line 1967.Add a guard after the catch block to bail out when no snapshot history exists:
catch (e) { console.warn('Yorkie undo failed, falling back to snapshot:', e); this.useYorkieUndo = false; + if (this.undoStack.length === 0) return; }Apply the symmetrical guard in
redo()forthis.redoStack.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/frontend/src/app/docs/yorkie-doc-store.ts` around lines 1927 - 1948, When Yorkie history throws inside undo() (and symmetrically in redo()), the code flips useYorkieUndo to false and falls through to the snapshot-based path but blindly pops from undoStack/redoStack which are empty because snapshot() was a no-op; fix by adding a guard immediately after the catch that checks if this.undoStack.length === 0 (for undo) / this.redoStack.length === 0 (for redo') and simply return (no-op) if empty to avoid popping undefined and calling writeFullDocument with an invalid document; update undo() and redo() around the catch blocks where useYorkieUndo is set to false and reference the existing methods/fields (undo(), redo(), snapshot(), undoStack, redoStack, writeFullDocument, useYorkieUndo) when applying the guard.
🧹 Nitpick comments (3)
packages/frontend/tests/app/docs/yorkie-doc-store.test.ts (1)
1657-1667: Consider asserting the post-undo document state, not justcanRedo.After
setDocument({ blocks: [block] })and a singleundo(), the document should revert to whatever pre-setDocumentstate Yorkie tracked (effectively empty). AssertinggetDocument().blocks.length === 0(or similar) here would catch regressions whereundo()no-ops silently or only the redo flag flips without actually rewinding the tree.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/frontend/tests/app/docs/yorkie-doc-store.test.ts` around lines 1657 - 1667, The test currently only checks canRedo after calling yStore.undo(); update the test to also assert the document state actually reverted by calling yStore.getDocument() and verifying blocks is empty (e.g., getDocument().blocks.length === 0 or deep-equal to the initial empty state) after undo; locate the test using symbols makeBlock, yStore.setDocument, yStore.undo, yStore.canRedo and add a post-undo assertion that confirms the tree was rewound, not just the redo flag toggled.packages/frontend/src/app/docs/yorkie-doc-store.ts (1)
1936-1939: One-way silent fallback loses all undo history — consider snapshotting on flip.Once
useYorkieUndoflips tofalse, the snapshot stacks are empty and stay that way until the next mutation triggerssnapshot(). The user then has no way to undo anything that happened before the failedmergeBlockundo (which arguably is the action they most wanted to revert). Two mitigations worth considering:
- On flip, push a clone of the current document onto
undoStackso at least the post-failure state is recoverable.- Surface the flip via
onRemoteChange?.()(or a newonUndoModeChangecallback) so the host UI can disable the redo button / show a toast, sincecanUndo()/canRedo()semantics shift the moment the flag flips.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/frontend/src/app/docs/yorkie-doc-store.ts` around lines 1936 - 1939, When catching the failed Yorkie undo in the catch block that sets useYorkieUndo = false (around the mergeBlock undo path), preserve undo history and notify the UI: push a deep clone of the current document state onto undoStack (or call snapshot()) immediately before or when flipping useYorkieUndo so users can still undo the last action, and call onRemoteChange?.() or emit a new onUndoModeChange(...) callback right after flipping the flag so host UI can update canUndo()/canRedo() and show a toast/disable redo; update references around useYorkieUndo, undoStack, snapshot(), mergeBlock, and the onRemoteChange/onUndoModeChange hooks accordingly.docs/tasks/active/20260403-intent-preserving-phase9-undo-redo-todo.md (1)
45-47: Tie the Backspace-merge skip to the fallback bug inyorkie-doc-store.ts.This skip is the same scenario that exposes the empty-snapshot-stack NPE in the
undo()fallback path (see review onpackages/frontend/src/app/docs/yorkie-doc-store.ts:1927-1948). Until that is fixed, hitting Backspace-merge-undo in the browser withuseYorkieUndo: truewon't just be "no-op"; it will throw. Worth adding a note here so whoever picks up the production rollout knows the prerequisite.As per coding guidelines:
docs/tasks/active/*-{todo,lessons}.md: For non-trivial tasks, use paired files in docs/tasks/active/.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@docs/tasks/active/20260403-intent-preserving-phase9-undo-redo-todo.md` around lines 45 - 47, The Backspace-merge test was skipped because triggering the undo() fallback path currently throws an empty-snapshot-stack NPE; fix the docs by adding a note tying that skip to the bug in the undo() fallback and add a paired lessons file as per the repo guideline. Update the todo entry to explicitly state: Backspace-merge→undo is blocked until the empty-snapshot-stack NPE in the undo() fallback (in yorkie-doc-store) is fixed; create the corresponding docs/tasks/active/*-lessons.md file summarizing the root cause (empty snapshot stack handling) and the required code change (guard against empty snapshot stack or ensure snapshot push/pop invariants in the undo() fallback). Ensure you reference the undo() fallback path and the empty-snapshot-stack NPE in the new note so the production rollout owner knows the prerequisite.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Outside diff comments:
In `@packages/frontend/src/app/docs/yorkie-doc-store.ts`:
- Around line 1927-1948: When Yorkie history throws inside undo() (and
symmetrically in redo()), the code flips useYorkieUndo to false and falls
through to the snapshot-based path but blindly pops from undoStack/redoStack
which are empty because snapshot() was a no-op; fix by adding a guard
immediately after the catch that checks if this.undoStack.length === 0 (for
undo) / this.redoStack.length === 0 (for redo') and simply return (no-op) if
empty to avoid popping undefined and calling writeFullDocument with an invalid
document; update undo() and redo() around the catch blocks where useYorkieUndo
is set to false and reference the existing methods/fields (undo(), redo(),
snapshot(), undoStack, redoStack, writeFullDocument, useYorkieUndo) when
applying the guard.
---
Nitpick comments:
In `@docs/tasks/active/20260403-intent-preserving-phase9-undo-redo-todo.md`:
- Around line 45-47: The Backspace-merge test was skipped because triggering the
undo() fallback path currently throws an empty-snapshot-stack NPE; fix the docs
by adding a note tying that skip to the bug in the undo() fallback and add a
paired lessons file as per the repo guideline. Update the todo entry to
explicitly state: Backspace-merge→undo is blocked until the empty-snapshot-stack
NPE in the undo() fallback (in yorkie-doc-store) is fixed; create the
corresponding docs/tasks/active/*-lessons.md file summarizing the root cause
(empty snapshot stack handling) and the required code change (guard against
empty snapshot stack or ensure snapshot push/pop invariants in the undo()
fallback). Ensure you reference the undo() fallback path and the
empty-snapshot-stack NPE in the new note so the production rollout owner knows
the prerequisite.
In `@packages/frontend/src/app/docs/yorkie-doc-store.ts`:
- Around line 1936-1939: When catching the failed Yorkie undo in the catch block
that sets useYorkieUndo = false (around the mergeBlock undo path), preserve undo
history and notify the UI: push a deep clone of the current document state onto
undoStack (or call snapshot()) immediately before or when flipping useYorkieUndo
so users can still undo the last action, and call onRemoteChange?.() or emit a
new onUndoModeChange(...) callback right after flipping the flag so host UI can
update canUndo()/canRedo() and show a toast/disable redo; update references
around useYorkieUndo, undoStack, snapshot(), mergeBlock, and the
onRemoteChange/onUndoModeChange hooks accordingly.
In `@packages/frontend/tests/app/docs/yorkie-doc-store.test.ts`:
- Around line 1657-1667: The test currently only checks canRedo after calling
yStore.undo(); update the test to also assert the document state actually
reverted by calling yStore.getDocument() and verifying blocks is empty (e.g.,
getDocument().blocks.length === 0 or deep-equal to the initial empty state)
after undo; locate the test using symbols makeBlock, yStore.setDocument,
yStore.undo, yStore.canRedo and add a post-undo assertion that confirms the tree
was rewound, not just the redo flag toggled.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 096d1f48-c135-46cc-9310-781aba34ddb8
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (5)
docs/tasks/active/20260403-intent-preserving-phase9-undo-redo-todo.mdpackages/backend/package.jsonpackages/frontend/package.jsonpackages/frontend/src/app/docs/yorkie-doc-store.tspackages/frontend/tests/app/docs/yorkie-doc-store.test.ts
Snapshot-based undo/redo writes the full document tree on each undo, which can create duplicate paragraphs and conflicts with concurrent remote changes. Replace entirely with Yorkie-native doc.history API. Remove useYorkieUndo feature flag, undoStack/redoStack, and fallback logic. snapshot() is now a permanent no-op. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Summary
@yorkie-js/sdkand@yorkie-js/reactfrom 0.7.6 to 0.7.7 (splitLevel>=2 undo/redo)YorkieDocStore— full document cloning on each undo caused duplicate paragraphs and conflicts with concurrent remote changesdoc.historyAPI;snapshot()is now a no-opKnown Limitations
mergeBlockundo not yet supported by Yorkie Tree (editByPathmerge reversal) — test skippedupdateBlock/updateTableCellcalls (Phase 6-8 scope) bypassdoc.update()boundaries, so those operations are not yet individually undoableTest plan
pnpm verify:fastpasses (622 tests)🤖 Generated with Claude Code