Release-cycle review found two hardening gaps: - The sync plugin deleted+rebuilt the WHOLE footnotesList on any reorder/orphan, replacing every definition's Yjs subtree -> a collaborator typing in a definition could lose in-flight characters on merge. Rework to targeted, minimal mutations: attr-only setNodeMarkup for collision re-ids, delete only genuine orphans, insert only genuinely-missing definitions (at the list end, not shifting existing subtrees), and consolidate multiple lists only in the abnormal paste/merge case. An unchanged (correct id, referenced) definition is left completely untouched. Numbering is decoration-only, so physical list order may drift after a reorder (accepted) while displayed numbers stay correct. Invariants preserved (reviewed + tested): one SYNC_META transaction, null when canonical (terminates), deterministic deriveFootnoteId, remote-skip -> no re-introduced freeze or divergence. - computeFootnoteNumbers ran per-NodeView-render (O(n^2)/keystroke in big docs). The numbering plugin now caches the number map in its state (computed once per docChanged); NodeViews read it O(1) via getFootnoteNumber. Tests: no-rebuild-on-reorder asserts unchanged definition node subtrees are identity-preserved; isRemoteTransaction skip; enableSync:false read-only; cache correctness. Browser re-smoke: insert (no freeze), number, persist across reload, cascade delete all pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
949 lines
33 KiB
TypeScript
949 lines
33 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { Editor, Extension, getSchema } from "@tiptap/core";
|
|
import { Document } from "@tiptap/extension-document";
|
|
import { Paragraph } from "@tiptap/extension-paragraph";
|
|
import { Text } from "@tiptap/extension-text";
|
|
import { Superscript } from "@tiptap/extension-superscript";
|
|
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
|
import { Node as PMNode } from "@tiptap/pm/model";
|
|
import { EditorState } from "@tiptap/pm/state";
|
|
import { FootnoteReference } from "./footnote-reference";
|
|
import { FootnotesList } from "./footnotes-list";
|
|
import { FootnoteDefinition } from "./footnote-definition";
|
|
import { TrailingNode } from "../trailing-node";
|
|
import { footnoteSyncPlugin } from "./footnote-sync";
|
|
import { getFootnoteNumber } from "./footnote-numbering";
|
|
import {
|
|
computeFootnoteNumbers,
|
|
collectReferenceIds,
|
|
FOOTNOTE_REFERENCE_NAME,
|
|
FOOTNOTES_LIST_NAME,
|
|
FOOTNOTE_DEFINITION_NAME,
|
|
} from "./footnote-util";
|
|
|
|
const extensions = [
|
|
Document,
|
|
Paragraph,
|
|
Text,
|
|
FootnoteReference,
|
|
FootnotesList,
|
|
FootnoteDefinition,
|
|
];
|
|
|
|
function makeEditor(content?: any) {
|
|
return new Editor({
|
|
extensions,
|
|
content: content ?? { type: "doc", content: [{ type: "paragraph" }] },
|
|
});
|
|
}
|
|
|
|
function countType(doc: PMNode, name: string): number {
|
|
let n = 0;
|
|
doc.descendants((node) => {
|
|
if (node.type.name === name) n++;
|
|
});
|
|
return n;
|
|
}
|
|
|
|
describe("footnote numbering (pure function)", () => {
|
|
it("numbers references in document order", () => {
|
|
const schema = getSchema(extensions);
|
|
const doc = PMNode.fromJSON(schema, {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [
|
|
{ type: "text", text: "a" },
|
|
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "x" } },
|
|
{ type: "text", text: "b" },
|
|
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "y" } },
|
|
],
|
|
},
|
|
{
|
|
type: FOOTNOTES_LIST_NAME,
|
|
content: [
|
|
{
|
|
type: FOOTNOTE_DEFINITION_NAME,
|
|
attrs: { id: "x" },
|
|
content: [{ type: "paragraph" }],
|
|
},
|
|
{
|
|
type: FOOTNOTE_DEFINITION_NAME,
|
|
attrs: { id: "y" },
|
|
content: [{ type: "paragraph" }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(collectReferenceIds(doc)).toEqual(["x", "y"]);
|
|
const numbers = computeFootnoteNumbers(doc);
|
|
expect(numbers.get("x")).toBe(1);
|
|
expect(numbers.get("y")).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe("setFootnote command", () => {
|
|
it("inserts a reference and a matching definition in the footnotes list", () => {
|
|
const editor = makeEditor({
|
|
type: "doc",
|
|
content: [
|
|
{ type: "paragraph", content: [{ type: "text", text: "Hello" }] },
|
|
],
|
|
});
|
|
// Cursor at end of the word.
|
|
editor.commands.setTextSelection(6);
|
|
const ok = editor.commands.setFootnote();
|
|
expect(ok).toBe(true);
|
|
|
|
const doc = editor.state.doc;
|
|
expect(countType(doc, FOOTNOTE_REFERENCE_NAME)).toBe(1);
|
|
expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1);
|
|
expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(1);
|
|
|
|
// The reference id and the definition id match.
|
|
let refId: string | null = null;
|
|
let defId: string | null = null;
|
|
doc.descendants((node) => {
|
|
if (node.type.name === FOOTNOTE_REFERENCE_NAME) refId = node.attrs.id;
|
|
if (node.type.name === FOOTNOTE_DEFINITION_NAME) defId = node.attrs.id;
|
|
});
|
|
expect(refId).toBeTruthy();
|
|
expect(refId).toBe(defId);
|
|
editor.destroy();
|
|
});
|
|
|
|
it("inserts the definition at the correct position matching reference order", () => {
|
|
const editor = makeEditor({
|
|
type: "doc",
|
|
content: [
|
|
{ type: "paragraph", content: [{ type: "text", text: "AAAA" }] },
|
|
{ type: "paragraph", content: [{ type: "text", text: "BBBB" }] },
|
|
],
|
|
});
|
|
|
|
// First footnote: place inside the SECOND paragraph (after "BBBB").
|
|
editor.commands.setTextSelection(11); // end of BBBB
|
|
editor.commands.setFootnote();
|
|
|
|
// Second footnote: place inside the FIRST paragraph (after "AAAA"),
|
|
// which is BEFORE the first reference in document order.
|
|
editor.commands.setTextSelection(5); // end of AAAA
|
|
editor.commands.setFootnote();
|
|
|
|
const doc = editor.state.doc;
|
|
// Reference order in document.
|
|
const refOrder = collectReferenceIds(doc);
|
|
// Definition order in the list.
|
|
const defOrder: string[] = [];
|
|
doc.descendants((node) => {
|
|
if (node.type.name === FOOTNOTE_DEFINITION_NAME) {
|
|
defOrder.push(node.attrs.id);
|
|
}
|
|
});
|
|
|
|
expect(defOrder).toEqual(refOrder);
|
|
expect(defOrder.length).toBe(2);
|
|
editor.destroy();
|
|
});
|
|
});
|
|
|
|
describe("removeFootnote command (cascade)", () => {
|
|
it("removes both the reference and its definition, and drops the empty list", () => {
|
|
const editor = makeEditor({
|
|
type: "doc",
|
|
content: [
|
|
{ type: "paragraph", content: [{ type: "text", text: "Hello" }] },
|
|
],
|
|
});
|
|
editor.commands.setTextSelection(6);
|
|
editor.commands.setFootnote();
|
|
|
|
let id: string | null = null;
|
|
editor.state.doc.descendants((node) => {
|
|
if (node.type.name === FOOTNOTE_REFERENCE_NAME) id = node.attrs.id;
|
|
});
|
|
expect(id).toBeTruthy();
|
|
|
|
editor.commands.removeFootnote(id!);
|
|
|
|
const doc = editor.state.doc;
|
|
expect(countType(doc, FOOTNOTE_REFERENCE_NAME)).toBe(0);
|
|
expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(0);
|
|
// empty list removed
|
|
expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(0);
|
|
editor.destroy();
|
|
});
|
|
});
|
|
|
|
describe("footnote sync plugin (orphans)", () => {
|
|
it("creates an empty definition for a reference pasted without one", () => {
|
|
const editor = makeEditor({
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [
|
|
{ type: "text", text: "x" },
|
|
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "orphan-ref" } },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
// Trigger a doc change so appendTransaction runs.
|
|
editor.commands.insertContentAt(1, " ");
|
|
|
|
const doc = editor.state.doc;
|
|
let defFound = false;
|
|
doc.descendants((node) => {
|
|
if (
|
|
node.type.name === FOOTNOTE_DEFINITION_NAME &&
|
|
node.attrs.id === "orphan-ref"
|
|
) {
|
|
defFound = true;
|
|
}
|
|
});
|
|
expect(defFound).toBe(true);
|
|
editor.destroy();
|
|
});
|
|
|
|
it("merges multiple footnotesList nodes into one, preserving all definitions, as the last child", () => {
|
|
const editor = makeEditor({
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [
|
|
{ type: "text", text: "a" },
|
|
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "x" } },
|
|
{ type: "text", text: "b" },
|
|
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "y" } },
|
|
],
|
|
},
|
|
// First (stray) footnotes list, e.g. from a paste/collab merge.
|
|
{
|
|
type: FOOTNOTES_LIST_NAME,
|
|
content: [
|
|
{
|
|
type: FOOTNOTE_DEFINITION_NAME,
|
|
attrs: { id: "x" },
|
|
content: [{ type: "paragraph", content: [{ type: "text", text: "X note" }] }],
|
|
},
|
|
],
|
|
},
|
|
{ type: "paragraph", content: [{ type: "text", text: "tail" }] },
|
|
// Second footnotes list (the "real" trailing one).
|
|
{
|
|
type: FOOTNOTES_LIST_NAME,
|
|
content: [
|
|
{
|
|
type: FOOTNOTE_DEFINITION_NAME,
|
|
attrs: { id: "y" },
|
|
content: [{ type: "paragraph", content: [{ type: "text", text: "Y note" }] }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
// Trigger a local doc change so appendTransaction runs.
|
|
editor.commands.insertContentAt(1, " ");
|
|
|
|
const doc = editor.state.doc;
|
|
// Converged to exactly ONE list.
|
|
expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1);
|
|
// Both definitions preserved (no tracking lost).
|
|
const defIds: string[] = [];
|
|
doc.descendants((node) => {
|
|
if (node.type.name === FOOTNOTE_DEFINITION_NAME) defIds.push(node.attrs.id);
|
|
});
|
|
expect(defIds.sort()).toEqual(["x", "y"]);
|
|
// The single list is the LAST child of the document.
|
|
const lastChild = doc.child(doc.childCount - 1);
|
|
expect(lastChild.type.name).toBe(FOOTNOTES_LIST_NAME);
|
|
editor.destroy();
|
|
});
|
|
|
|
it("leaves a correct doc (single trailing list) unchanged — no merge loop", () => {
|
|
const editor = makeEditor({
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [
|
|
{ type: "text", text: "a" },
|
|
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "x" } },
|
|
],
|
|
},
|
|
{
|
|
type: FOOTNOTES_LIST_NAME,
|
|
content: [
|
|
{
|
|
type: FOOTNOTE_DEFINITION_NAME,
|
|
attrs: { id: "x" },
|
|
content: [{ type: "paragraph", content: [{ type: "text", text: "X note" }] }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
const before = editor.state.doc.toJSON();
|
|
// A change that doesn't touch footnote structure.
|
|
editor.commands.insertContentAt(1, "z");
|
|
const doc = editor.state.doc;
|
|
// Still exactly one list, still last, definition preserved.
|
|
expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1);
|
|
const lastChild = doc.child(doc.childCount - 1);
|
|
expect(lastChild.type.name).toBe(FOOTNOTES_LIST_NAME);
|
|
// The footnotes list subtree is identical to before (no spurious rewrite).
|
|
const beforeList = before.content.find(
|
|
(n: any) => n.type === FOOTNOTES_LIST_NAME,
|
|
);
|
|
const afterList = doc
|
|
.toJSON()
|
|
.content.find((n: any) => n.type === FOOTNOTES_LIST_NAME);
|
|
expect(afterList).toEqual(beforeList);
|
|
editor.destroy();
|
|
});
|
|
|
|
it("two definitions sharing an id (with two matching references) BOTH survive the first edit (no data loss)", () => {
|
|
// Reproduces the verified data-loss bug: two footnoteDefinition nodes share
|
|
// id "d", and there are two references with id "d". The OLD code built the
|
|
// definitions Map last-wins and emitted exactly one definition for the
|
|
// de-duplicated reference, so the very first keystroke's sync transaction
|
|
// deleted the whole list and rebuilt it from one definition — silently
|
|
// destroying "first" and keeping only "second".
|
|
const editor = makeEditor({
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [
|
|
{ type: "text", text: "a" },
|
|
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "d" } },
|
|
{ type: "text", text: "b" },
|
|
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "d" } },
|
|
],
|
|
},
|
|
{
|
|
type: FOOTNOTES_LIST_NAME,
|
|
content: [
|
|
{
|
|
type: FOOTNOTE_DEFINITION_NAME,
|
|
attrs: { id: "d" },
|
|
content: [
|
|
{ type: "paragraph", content: [{ type: "text", text: "first" }] },
|
|
],
|
|
},
|
|
{
|
|
type: FOOTNOTE_DEFINITION_NAME,
|
|
attrs: { id: "d" },
|
|
content: [
|
|
{ type: "paragraph", content: [{ type: "text", text: "second" }] },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
// The first local keystroke fires the sync plugin's appendTransaction.
|
|
editor.commands.insertContentAt(1, " ");
|
|
|
|
const doc = editor.state.doc;
|
|
// BOTH definitions survive.
|
|
expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(2);
|
|
const defTexts: string[] = [];
|
|
const defIds: string[] = [];
|
|
doc.descendants((node) => {
|
|
if (node.type.name === FOOTNOTE_DEFINITION_NAME) {
|
|
defIds.push(node.attrs.id);
|
|
defTexts.push(node.textContent);
|
|
}
|
|
});
|
|
// No content was lost: both "first" and "second" are still present.
|
|
expect(defTexts.sort()).toEqual(["first", "second"]);
|
|
// The colliding ids were made distinct.
|
|
expect(new Set(defIds).size).toBe(2);
|
|
// Each definition's id matches exactly one reference (1:1 pairing).
|
|
const refIds: string[] = [];
|
|
doc.descendants((node) => {
|
|
if (node.type.name === FOOTNOTE_REFERENCE_NAME) refIds.push(node.attrs.id);
|
|
});
|
|
expect(refIds.sort()).toEqual(defIds.sort());
|
|
editor.destroy();
|
|
});
|
|
|
|
it("re-ids colliding duplicates DETERMINISTICALLY (two clients converge to identical ids)", () => {
|
|
// Cross-client determinism guard. Two collaborating clients each see the
|
|
// SAME duplicate-id document and each make a local edit. The sync plugin
|
|
// runs identically on every client, so it MUST mint the SAME new ids on both
|
|
// — otherwise the two clients diverge permanently over Yjs (duplicated
|
|
// footnotes). This is exactly the blocker the previous random-id
|
|
// (generateFootnoteId / Math.random) implementation caused: it would mint
|
|
// DIFFERENT ids on each client and this assertion would fail.
|
|
const duplicateDoc = {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [
|
|
{ type: "text", text: "a" },
|
|
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "d" } },
|
|
{ type: "text", text: "b" },
|
|
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "d" } },
|
|
{ type: "text", text: "c" },
|
|
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "d" } },
|
|
],
|
|
},
|
|
{
|
|
type: FOOTNOTES_LIST_NAME,
|
|
content: [
|
|
{
|
|
type: FOOTNOTE_DEFINITION_NAME,
|
|
attrs: { id: "d" },
|
|
content: [
|
|
{ type: "paragraph", content: [{ type: "text", text: "one" }] },
|
|
],
|
|
},
|
|
{
|
|
type: FOOTNOTE_DEFINITION_NAME,
|
|
attrs: { id: "d" },
|
|
content: [
|
|
{ type: "paragraph", content: [{ type: "text", text: "two" }] },
|
|
],
|
|
},
|
|
{
|
|
type: FOOTNOTE_DEFINITION_NAME,
|
|
attrs: { id: "d" },
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [{ type: "text", text: "three" }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
const idsAfterLocalEdit = () => {
|
|
// A fresh editor instance = an independent "client" running the same
|
|
// plugin pipeline on the same starting document.
|
|
const editor = makeEditor(structuredClone(duplicateDoc));
|
|
editor.commands.insertContentAt(1, " "); // local keystroke -> sync runs
|
|
const refIds: string[] = [];
|
|
const defIds: string[] = [];
|
|
editor.state.doc.descendants((node) => {
|
|
if (node.type.name === FOOTNOTE_REFERENCE_NAME)
|
|
refIds.push(node.attrs.id);
|
|
if (node.type.name === FOOTNOTE_DEFINITION_NAME)
|
|
defIds.push(node.attrs.id);
|
|
});
|
|
editor.destroy();
|
|
return { refIds, defIds };
|
|
};
|
|
|
|
const clientA = idsAfterLocalEdit();
|
|
const clientB = idsAfterLocalEdit();
|
|
|
|
// Both clients computed IDENTICAL ids (the property that makes Yjs converge).
|
|
expect(clientA.refIds).toEqual(clientB.refIds);
|
|
expect(clientA.defIds).toEqual(clientB.defIds);
|
|
|
|
// And the ids are deterministic-derived (not random uuid-style): the keeper
|
|
// keeps "d", the duplicates become "d__2", "d__3".
|
|
expect(new Set(clientA.refIds)).toEqual(new Set(["d", "d__2", "d__3"]));
|
|
// Every definition survived with a unique id, 1:1 with the references.
|
|
expect(clientA.defIds.length).toBe(3);
|
|
expect(new Set(clientA.defIds).size).toBe(3);
|
|
expect([...clientA.refIds].sort()).toEqual([...clientA.defIds].sort());
|
|
});
|
|
|
|
it("removes an orphan definition with no matching reference", () => {
|
|
const editor = makeEditor({
|
|
type: "doc",
|
|
content: [
|
|
{ type: "paragraph", content: [{ type: "text", text: "x" }] },
|
|
{
|
|
type: FOOTNOTES_LIST_NAME,
|
|
content: [
|
|
{
|
|
type: FOOTNOTE_DEFINITION_NAME,
|
|
attrs: { id: "orphan-def" },
|
|
content: [{ type: "paragraph" }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
editor.commands.insertContentAt(1, "y");
|
|
|
|
const doc = editor.state.doc;
|
|
expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(0);
|
|
expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(0);
|
|
editor.destroy();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Live-editor regression tests for the sync-plugin infinite loop (the hard
|
|
* freeze when activating /footnote). These drive a REAL Tiptap editor through
|
|
* the same plugin pipeline the browser uses — including the TrailingNode plugin,
|
|
* which is what turned the "move list to the end" pass into an infinite
|
|
* ping-pong (list moved last -> trailing paragraph appended after it -> list no
|
|
* longer last -> moved again -> ...).
|
|
*
|
|
* If the loop regresses, ProseMirror's appendTransaction round loop never
|
|
* terminates and these tests HANG (the vitest timeout fails them). The
|
|
* transaction counter additionally fails fast with a bounded iteration cap, so
|
|
* a regression surfaces as an explicit error instead of only a slow timeout.
|
|
*/
|
|
describe("footnote sync plugin (no infinite loop — live editor)", () => {
|
|
// Hard cap on how many doc-changing appendTransaction rounds we tolerate for a
|
|
// single user action. Convergence takes a couple of rounds at most; anything
|
|
// approaching this means the plugins are oscillating.
|
|
const MAX_ROUNDS = 50;
|
|
|
|
// The production editor wires FootnoteReference alongside TrailingNode and
|
|
// Superscript; both participate in the loop the bug exhibited, so we mirror
|
|
// that here.
|
|
function makeLiveEditor(content?: any) {
|
|
let rounds = 0;
|
|
// A guard plugin that counts doc-changing appendTransaction rounds and
|
|
// throws if they exceed the cap, converting a would-be infinite loop into a
|
|
// deterministic failure instead of a wall-clock hang.
|
|
const LoopGuard = Extension.create({
|
|
name: "footnoteLoopGuard",
|
|
// Run last so it observes every other plugin's appended transaction.
|
|
priority: -1000,
|
|
addProseMirrorPlugins() {
|
|
return [
|
|
new Plugin({
|
|
key: new PluginKey("footnoteLoopGuard"),
|
|
appendTransaction(transactions) {
|
|
if (transactions.some((t) => t.docChanged)) {
|
|
rounds += 1;
|
|
if (rounds > MAX_ROUNDS) {
|
|
throw new Error(
|
|
`footnote sync did not converge: exceeded ${MAX_ROUNDS} appendTransaction rounds (infinite loop)`,
|
|
);
|
|
}
|
|
}
|
|
return null;
|
|
},
|
|
}),
|
|
];
|
|
},
|
|
});
|
|
|
|
const editor = new Editor({
|
|
extensions: [
|
|
Document,
|
|
Paragraph,
|
|
Text,
|
|
Superscript,
|
|
TrailingNode,
|
|
LoopGuard,
|
|
FootnoteReference,
|
|
FootnotesList,
|
|
FootnoteDefinition,
|
|
],
|
|
content: content ?? { type: "doc", content: [{ type: "paragraph" }] },
|
|
});
|
|
return { editor, getRounds: () => rounds, resetRounds: () => (rounds = 0) };
|
|
}
|
|
|
|
function lastFootnotesListIsTrailing(doc: PMNode): boolean {
|
|
// Canonical placement: the list is the last meaningful block — only empty
|
|
// paragraphs (the trailing-node) may follow it.
|
|
let listIndex = -1;
|
|
for (let i = 0; i < doc.childCount; i++) {
|
|
if (doc.child(i).type.name === FOOTNOTES_LIST_NAME) listIndex = i;
|
|
}
|
|
if (listIndex === -1) return false;
|
|
for (let i = listIndex + 1; i < doc.childCount; i++) {
|
|
const child = doc.child(i);
|
|
if (!(child.type.name === "paragraph" && child.content.size === 0)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
it("setFootnote() RETURNS (no hang) and produces one ref + one def in a trailing list", () => {
|
|
const { editor } = makeLiveEditor({
|
|
type: "doc",
|
|
content: [{ type: "paragraph", content: [{ type: "text", text: "Hi" }] }],
|
|
});
|
|
editor.commands.setTextSelection(3);
|
|
const ok = editor.commands.setFootnote();
|
|
expect(ok).toBe(true);
|
|
|
|
const doc = editor.state.doc;
|
|
expect(countType(doc, FOOTNOTE_REFERENCE_NAME)).toBe(1);
|
|
expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1);
|
|
expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(1);
|
|
expect(lastFootnotesListIsTrailing(doc)).toBe(true);
|
|
editor.destroy();
|
|
});
|
|
|
|
it("a second setFootnote() does not hang: two refs + two defs in one list", () => {
|
|
const { editor } = makeLiveEditor({
|
|
type: "doc",
|
|
content: [{ type: "paragraph", content: [{ type: "text", text: "Hi" }] }],
|
|
});
|
|
editor.commands.setTextSelection(3);
|
|
editor.commands.setFootnote();
|
|
editor.commands.setTextSelection(3);
|
|
editor.commands.setFootnote();
|
|
|
|
const doc = editor.state.doc;
|
|
expect(countType(doc, FOOTNOTE_REFERENCE_NAME)).toBe(2);
|
|
expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(2);
|
|
expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1);
|
|
expect(lastFootnotesListIsTrailing(doc)).toBe(true);
|
|
editor.destroy();
|
|
});
|
|
|
|
it("converges and stabilizes: an unrelated edit does not keep producing transactions", () => {
|
|
const { editor, getRounds, resetRounds } = makeLiveEditor({
|
|
type: "doc",
|
|
content: [{ type: "paragraph", content: [{ type: "text", text: "Hi" }] }],
|
|
});
|
|
editor.commands.setTextSelection(3);
|
|
editor.commands.setFootnote();
|
|
|
|
// Now the doc is canonical. Dispatch an unrelated edit (insert text) and
|
|
// assert the sync plugin converges in a bounded number of rounds and the
|
|
// document is stable (one ref/def/list, list trailing).
|
|
resetRounds();
|
|
editor.commands.insertContentAt(1, "Z");
|
|
const afterFirst = editor.state.doc.toJSON();
|
|
const roundsAfterEdit = getRounds();
|
|
expect(roundsAfterEdit).toBeLessThan(MAX_ROUNDS);
|
|
|
|
// A follow-up no-op-ish edit must not re-trigger structural rewrites: the
|
|
// footnotes section is identical before and after a further unrelated edit.
|
|
editor.commands.insertContentAt(2, "Y");
|
|
const afterSecond = editor.state.doc.toJSON();
|
|
|
|
const listOf = (json: any) =>
|
|
json.content.find((n: any) => n.type === FOOTNOTES_LIST_NAME);
|
|
expect(listOf(afterSecond)).toEqual(listOf(afterFirst));
|
|
expect(countType(editor.state.doc, FOOTNOTES_LIST_NAME)).toBe(1);
|
|
editor.destroy();
|
|
});
|
|
|
|
it("two footnotesList nodes converge to one (merge) without looping", () => {
|
|
const { editor } = makeLiveEditor({
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [
|
|
{ type: "text", text: "a" },
|
|
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "x" } },
|
|
{ type: "text", text: "b" },
|
|
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "y" } },
|
|
],
|
|
},
|
|
{
|
|
type: FOOTNOTES_LIST_NAME,
|
|
content: [
|
|
{
|
|
type: FOOTNOTE_DEFINITION_NAME,
|
|
attrs: { id: "x" },
|
|
content: [
|
|
{ type: "paragraph", content: [{ type: "text", text: "X" }] },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{ type: "paragraph", content: [{ type: "text", text: "tail" }] },
|
|
{
|
|
type: FOOTNOTES_LIST_NAME,
|
|
content: [
|
|
{
|
|
type: FOOTNOTE_DEFINITION_NAME,
|
|
attrs: { id: "y" },
|
|
content: [
|
|
{ type: "paragraph", content: [{ type: "text", text: "Y" }] },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
// Trigger a local doc change so appendTransaction runs (must not hang).
|
|
editor.commands.insertContentAt(1, " ");
|
|
|
|
const doc = editor.state.doc;
|
|
expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1);
|
|
const defIds: string[] = [];
|
|
doc.descendants((node) => {
|
|
if (node.type.name === FOOTNOTE_DEFINITION_NAME)
|
|
defIds.push(node.attrs.id);
|
|
});
|
|
expect(defIds.sort()).toEqual(["x", "y"]);
|
|
expect(lastFootnotesListIsTrailing(doc)).toBe(true);
|
|
editor.destroy();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Data-loss-window regression guard (Fix 1). A pure reference REORDER must not
|
|
* cause the sync plugin to delete-and-recreate any definition subtree — doing so
|
|
* (the previous behaviour) would, through Yjs, replace the CRDT subtree of every
|
|
* definition and could lose a collaborator's in-flight characters on merge.
|
|
*
|
|
* Numbering is decoration-only (footnote-numbering.ts derives numbers from
|
|
* reference order), so the bottom list's PHYSICAL order need not match reference
|
|
* order for the displayed numbers to be correct. We therefore assert: the
|
|
* existing definition NODE INSTANCES are preserved (identity-equal) after the
|
|
* sync pass, AND the derived numbers follow the new reference order.
|
|
*/
|
|
describe("footnote sync plugin (no rebuild on reorder — data-loss guard)", () => {
|
|
function reorderedDoc() {
|
|
// The "out of order" end-state of a reorder: references occur as [b, a] but
|
|
// the bottom list still physically holds definitions in [a, b] order. This
|
|
// is exactly the situation a reference reorder produces (decoration-only
|
|
// numbering keeps the displayed numbers correct without physically moving
|
|
// the definition subtrees). The sync plugin must leave the definitions
|
|
// ALONE here — no delete/recreate of any definition subtree.
|
|
return {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [
|
|
{ type: "text", text: "p" },
|
|
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "b" } },
|
|
{ type: "text", text: "q" },
|
|
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "a" } },
|
|
],
|
|
},
|
|
{
|
|
type: FOOTNOTES_LIST_NAME,
|
|
content: [
|
|
{
|
|
type: FOOTNOTE_DEFINITION_NAME,
|
|
attrs: { id: "a" },
|
|
content: [
|
|
{ type: "paragraph", content: [{ type: "text", text: "A" }] },
|
|
],
|
|
},
|
|
{
|
|
type: FOOTNOTE_DEFINITION_NAME,
|
|
attrs: { id: "b" },
|
|
content: [
|
|
{ type: "paragraph", content: [{ type: "text", text: "B" }] },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function getDefNodesById(doc: PMNode): Map<string, PMNode> {
|
|
const m = new Map<string, PMNode>();
|
|
doc.descendants((node) => {
|
|
if (node.type.name === FOOTNOTE_DEFINITION_NAME) m.set(node.attrs.id, node);
|
|
});
|
|
return m;
|
|
}
|
|
|
|
it("does NOT delete/recreate existing definition subtrees for an out-of-order list (numbers still correct)", () => {
|
|
const editor = makeEditor(reorderedDoc());
|
|
|
|
// Capture the exact definition NODE INSTANCES before any sync pass.
|
|
const before = getDefNodesById(editor.state.doc);
|
|
// Sanity: both carry their content right now.
|
|
expect(before.get("a")!.textContent).toBe("A");
|
|
expect(before.get("b")!.textContent).toBe("B");
|
|
|
|
// Trigger a local edit elsewhere in the body so the sync plugin runs.
|
|
editor.commands.insertContentAt(1, "z");
|
|
|
|
const doc = editor.state.doc;
|
|
|
|
// Reference order is [b, a]; the displayed numbers follow reference order
|
|
// (decoration-only numbering): b -> 1, a -> 2 — regardless of physical list
|
|
// order.
|
|
expect(collectReferenceIds(doc)).toEqual(["b", "a"]);
|
|
const numbers = computeFootnoteNumbers(doc);
|
|
expect(numbers.get("b")).toBe(1);
|
|
expect(numbers.get("a")).toBe(2);
|
|
|
|
// CRITICAL regression guard: both definitions still exist and are the SAME
|
|
// node instances as before the edit — the plugin did NOT delete/recreate the
|
|
// list (which would replace every definition's CRDT subtree and open the
|
|
// concurrent-edit data-loss window). Identity equality proves the subtree
|
|
// was preserved verbatim.
|
|
const after = getDefNodesById(doc);
|
|
expect(after.get("a")).toBe(before.get("a"));
|
|
expect(after.get("b")).toBe(before.get("b"));
|
|
// Content intact, exactly one list, both definitions present.
|
|
expect(after.get("a")!.textContent).toBe("A");
|
|
expect(after.get("b")!.textContent).toBe("B");
|
|
expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1);
|
|
expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(2);
|
|
|
|
editor.destroy();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Sync-plugin guard paths that are awkward to exercise through a live editor:
|
|
* the remote-transaction skip and the enableSync:false (read-only) mode.
|
|
*/
|
|
describe("footnote sync plugin (guards)", () => {
|
|
// Build a non-canonical document (an orphan reference with no definition) so a
|
|
// sync pass would normally append a transaction.
|
|
function nonCanonicalState() {
|
|
const schema = getSchema(extensions);
|
|
const doc = PMNode.fromJSON(schema, {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [
|
|
{ type: "text", text: "x" },
|
|
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "orphan" } },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
return EditorState.create({ schema, doc });
|
|
}
|
|
|
|
it("isRemoteTransaction => true: appendTransaction returns null (no rebuild on remote txns)", () => {
|
|
// The sync plugin must SKIP remote/collab transactions so orphan cleanup and
|
|
// structural rewrites only ever run on local edits.
|
|
const plugin = footnoteSyncPlugin(() => true);
|
|
const state = nonCanonicalState();
|
|
|
|
// Produce a doc-changing transaction (insert a space) and feed it to the
|
|
// plugin's appendTransaction exactly as ProseMirror would.
|
|
const tr = state.tr.insertText(" ", 1);
|
|
const newState = state.apply(tr);
|
|
const result = plugin.spec.appendTransaction!(
|
|
[tr],
|
|
state,
|
|
newState,
|
|
);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("isRemoteTransaction => false: appendTransaction DOES rebuild (sanity)", () => {
|
|
// Control: with a local (non-remote) transaction the same non-canonical doc
|
|
// triggers a sync transaction, proving the null above is the remote guard
|
|
// and not a no-op everywhere.
|
|
const plugin = footnoteSyncPlugin(() => false);
|
|
const state = nonCanonicalState();
|
|
const tr = state.tr.insertText(" ", 1);
|
|
const newState = state.apply(tr);
|
|
const result = plugin.spec.appendTransaction!([tr], state, newState);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.docChanged).toBe(true);
|
|
});
|
|
|
|
it("enableSync:false: the plugin never mutates the doc (read-only viewer)", () => {
|
|
// Build an editor with sync disabled. An orphan reference (no definition)
|
|
// must NOT trigger a definition insertion — the document is left untouched.
|
|
const editor = new Editor({
|
|
extensions: [
|
|
Document,
|
|
Paragraph,
|
|
Text,
|
|
FootnoteReference.configure({ enableSync: false }),
|
|
FootnotesList,
|
|
FootnoteDefinition,
|
|
],
|
|
content: {
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [
|
|
{ type: "text", text: "x" },
|
|
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "orphan" } },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
// A local edit that would normally trigger orphan-definition synthesis.
|
|
editor.commands.insertContentAt(1, "y");
|
|
|
|
const doc = editor.state.doc;
|
|
// No definition (and no list) was ever created — sync is disabled.
|
|
expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(0);
|
|
expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(0);
|
|
// Numbering decorations still work: the reference is numbered 1.
|
|
expect(getFootnoteNumber(editor.state, "orphan")).toBe(1);
|
|
editor.destroy();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Numbering cache (Fix 2). NodeViews must read footnote numbers from the
|
|
* numbering plugin's cached map (updated once per doc change) rather than
|
|
* recomputing the whole map per render. We assert the cache exists, is correct,
|
|
* and stays current across edits.
|
|
*/
|
|
describe("footnote numbering cache", () => {
|
|
it("exposes correct numbers via getFootnoteNumber and updates on edits", () => {
|
|
const editor = makeEditor({
|
|
type: "doc",
|
|
content: [
|
|
{
|
|
type: "paragraph",
|
|
content: [
|
|
{ type: "text", text: "a" },
|
|
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "x" } },
|
|
{ type: "text", text: "b" },
|
|
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "y" } },
|
|
],
|
|
},
|
|
{
|
|
type: FOOTNOTES_LIST_NAME,
|
|
content: [
|
|
{
|
|
type: FOOTNOTE_DEFINITION_NAME,
|
|
attrs: { id: "x" },
|
|
content: [{ type: "paragraph" }],
|
|
},
|
|
{
|
|
type: FOOTNOTE_DEFINITION_NAME,
|
|
attrs: { id: "y" },
|
|
content: [{ type: "paragraph" }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
// The cache mirrors computeFootnoteNumbers — but is read in O(1) per id.
|
|
expect(getFootnoteNumber(editor.state, "x")).toBe(1);
|
|
expect(getFootnoteNumber(editor.state, "y")).toBe(2);
|
|
// The cached map is the SAME values a fresh full computation would yield.
|
|
const fresh = computeFootnoteNumbers(editor.state.doc);
|
|
expect(getFootnoteNumber(editor.state, "x")).toBe(fresh.get("x"));
|
|
expect(getFootnoteNumber(editor.state, "y")).toBe(fresh.get("y"));
|
|
|
|
// After inserting a new earlier reference, the cache updates so the numbers
|
|
// shift (decoration-only numbering follows reference order).
|
|
editor.commands.insertContentAt(1, {
|
|
type: FOOTNOTE_REFERENCE_NAME,
|
|
attrs: { id: "z" },
|
|
});
|
|
expect(getFootnoteNumber(editor.state, "z")).toBe(1);
|
|
expect(getFootnoteNumber(editor.state, "x")).toBe(2);
|
|
expect(getFootnoteNumber(editor.state, "y")).toBe(3);
|
|
editor.destroy();
|
|
});
|
|
});
|