fix(editor): защитить чтение приватных стеков y-undo в тулбаре
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user