diff --git a/apps/client/src/features/editor/components/fixed-toolbar/use-toolbar-state.test.ts b/apps/client/src/features/editor/components/fixed-toolbar/use-toolbar-state.test.ts new file mode 100644 index 00000000..396925dc --- /dev/null +++ b/apps/client/src/features/editor/components/fixed-toolbar/use-toolbar-state.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from "vitest"; +import * as Y from "yjs"; +import { yHistoryAvailability } from "./use-toolbar-state.ts"; + +// Undo/redo availability is derived from the Yjs UndoManager's PRIVATE +// `undoStack` / `redoStack` fields (see use-toolbar-state.ts for why we read the +// stack lengths directly instead of the expensive `editor.can().undo()` dry-run). +// These tests lock in the behavior AND pin the library shape so a yjs / y-undo +// upgrade that renames/restructures those internals fails loudly here rather than +// silently enabling/disabling the toolbar buttons in production. +describe("yHistoryAvailability", () => { + it("reports availability from the stack lengths", () => { + expect(yHistoryAvailability({ undoStack: [], redoStack: [] })).toEqual({ + canUndo: false, + canRedo: false, + }); + expect( + yHistoryAvailability({ undoStack: [{}], redoStack: [] }), + ).toEqual({ canUndo: true, canRedo: false }); + expect( + yHistoryAvailability({ undoStack: [{}], redoStack: [{}, {}] }), + ).toEqual({ canUndo: true, canRedo: true }); + }); + + it("returns null when the private stack shape is unrecognized (upgrade guard)", () => { + // Simulates a yjs / y-undo upgrade that renames or restructures the private + // fields: the caller then falls back to the safe prosemirror-history default + // instead of throwing on `.length` of undefined or reading garbage. + expect(yHistoryAvailability(undefined)).toBeNull(); + expect(yHistoryAvailability(null)).toBeNull(); + expect(yHistoryAvailability({})).toBeNull(); + expect(yHistoryAvailability({ undoStack: 5, redoStack: 5 })).toBeNull(); + // Only one stack present (partial rename) is still not trusted. + expect(yHistoryAvailability({ undoStack: [] })).toBeNull(); + }); + + it("pin-test: a real yjs UndoManager still exposes undoStack/redoStack arrays", () => { + const doc = new Y.Doc(); + const text = doc.getText("prosemirror"); + const undoManager = new Y.UndoManager(text); + + // Fresh manager: both stacks empty -> nothing to undo/redo. + expect(yHistoryAvailability(undoManager)).toEqual({ + canUndo: false, + canRedo: false, + }); + + // A tracked edit must push onto the private undoStack. If a future yjs + // renames these fields, yHistoryAvailability(undoManager) returns null and + // the expectation below fails loudly. + text.insert(0, "hello"); + undoManager.stopCapturing(); + expect(yHistoryAvailability(undoManager)).toEqual({ + canUndo: true, + canRedo: false, + }); + + // Undoing moves the item to the redoStack -> redo becomes available. + undoManager.undo(); + expect(yHistoryAvailability(undoManager)).toEqual({ + canUndo: false, + canRedo: true, + }); + }); +}); diff --git a/apps/client/src/features/editor/components/fixed-toolbar/use-toolbar-state.ts b/apps/client/src/features/editor/components/fixed-toolbar/use-toolbar-state.ts index 2f8e3a7f..ec47be69 100644 --- a/apps/client/src/features/editor/components/fixed-toolbar/use-toolbar-state.ts +++ b/apps/client/src/features/editor/components/fixed-toolbar/use-toolbar-state.ts @@ -35,6 +35,30 @@ export interface ToolbarState { // When neither history backend is installed (the pre-sync static editor — // mainExtensions only, undoRedo disabled), both fall through to 0 -> false, // matching the previous `safeCan` behavior. +// Reads the Yjs UndoManager's undo/redo availability from its stack lengths. +// +// `undoStack` / `redoStack` are PRIVATE y-undo / yjs internals, so we touch them +// defensively: a yjs or y-undo upgrade that renames or restructures these fields +// must not silently mis-drive the toolbar buttons (nor throw on `.length` of +// `undefined`). We only trust them when they are actually arrays; otherwise this +// returns null and the caller falls back to a safe default. The pin-test in +// use-toolbar-state.test.ts asserts the current library shape, so an upgrade that +// breaks this contract fails loudly there instead of failing silently in the UI. +export function yHistoryAvailability( + undoManager: unknown, +): { canUndo: boolean; canRedo: boolean } | null { + if (!undoManager || typeof undoManager !== "object") return null; + const { undoStack, redoStack } = undoManager as { + undoStack?: unknown; + redoStack?: unknown; + }; + if (!Array.isArray(undoStack) || !Array.isArray(redoStack)) return null; + return { + canUndo: undoStack.length > 0, + canRedo: redoStack.length > 0, + }; +} + function historyAvailability(editor: Editor): { canUndo: boolean; canRedo: boolean; @@ -43,16 +67,14 @@ function historyAvailability(editor: Editor): { // Collaboration history (Yjs) takes precedence when present. const yState = yUndoPluginKey.getState(state) as - | { undoManager?: { undoStack: unknown[]; redoStack: unknown[] } } + | { undoManager?: unknown } | undefined; - if (yState?.undoManager) { - return { - canUndo: yState.undoManager.undoStack.length > 0, - canRedo: yState.undoManager.redoStack.length > 0, - }; - } + const yAvail = yHistoryAvailability(yState?.undoManager); + if (yAvail) return yAvail; // Plain prosemirror-history (returns 0 when the history plugin is absent). + // This is also the safe default when a Yjs UndoManager is present but its + // private stack shape is no longer recognized (yHistoryAvailability -> null). return { canUndo: undoDepth(state) > 0, canRedo: redoDepth(state) > 0,