From 173f35e47396894fcf34c46ab6ecef8d2ded40b6 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Sat, 11 Jul 2026 18:47:39 +0300 Subject: [PATCH] =?UTF-8?q?fix(editor):=20=D0=B7=D0=B0=D1=89=D0=B8=D1=82?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=87=D1=82=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D0=B2=D0=B0=D1=82=D0=BD=D1=8B=D1=85=20=D1=81?= =?UTF-8?q?=D1=82=D0=B5=D0=BA=D0=BE=D0=B2=20y-undo=20=D0=B2=20=D1=82=D1=83?= =?UTF-8?q?=D0=BB=D0=B1=D0=B0=D1=80=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit canUndo/canRedo в use-toolbar-state читали приватные внутренности y-undo (undoManager.undoStack.length / redoStack.length). Апгрейд yjs / y-undo, переименовавший или перестроивший эти поля, тихо сломал бы состояние кнопок undo/redo (или упал бы на .length у undefined) без единой ошибки. Оставляем дешёвое чтение длины стеков (сознательно не используем дорогой editor.can().undo()/.redo(), который делает dry-run на каждый keystroke, см. комментарий в файле), но теперь feature-detect: доверяем стекам только если это реально массивы, иначе откатываемся на безопасный дефолт (prosemirror-history undoDepth/redoDepth -> 0). Логика вынесена в чистую функцию yHistoryAvailability. Добавлен pin-test, фиксирующий текущую форму библиотеки: реальный Y.UndoManager по-прежнему отдаёт undoStack/redoStack массивами. Апгрейд, меняющий контракт, упадёт громко в тесте, а не тихо в UI. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../fixed-toolbar/use-toolbar-state.test.ts | 65 +++++++++++++++++++ .../fixed-toolbar/use-toolbar-state.ts | 36 ++++++++-- 2 files changed, 94 insertions(+), 7 deletions(-) create mode 100644 apps/client/src/features/editor/components/fixed-toolbar/use-toolbar-state.test.ts 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,