diff --git a/package.json b/package.json index 9dfde2719f..b24f168ca6 100644 --- a/package.json +++ b/package.json @@ -88,17 +88,7 @@ "overrides": { "rollup": ">=4.59.0", "react": "^19.2.8", - "react-dom": "^19.2.8", - "lexical": "0.49.0", - "@lexical/clipboard": "0.49.0", - "@lexical/link": "0.49.0", - "@lexical/list": "0.49.0", - "@lexical/markdown": "0.49.0", - "@lexical/plain-text": "0.49.0", - "@lexical/react": "0.49.0", - "@lexical/rich-text": "0.49.0", - "@lexical/selection": "0.49.0", - "@lexical/utils": "0.49.0" + "react-dom": "^19.2.8" } } } diff --git a/ui/package.json b/ui/package.json index 520d91b203..38ccf73e10 100644 --- a/ui/package.json +++ b/ui/package.json @@ -33,7 +33,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@lexical/link": "0.49.0", + "@lexical/link": "0.48.0", "@mdxeditor/editor": "^4.2.1", "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", @@ -57,7 +57,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "i18next": "^26.3.6", - "lexical": "0.49.0", + "lexical": "0.48.0", "lucide-react": "^1.32.0", "mermaid": "^11.16.1", "motion": "^12.42.2", diff --git a/ui/src/lib/lexical-single-copy.test.ts b/ui/src/lib/lexical-single-copy.test.ts new file mode 100644 index 0000000000..5ff3eddc4d --- /dev/null +++ b/ui/src/lib/lexical-single-copy.test.ts @@ -0,0 +1,66 @@ +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join, parse } from "node:path"; +import { describe, expect, it } from "vitest"; + +/** + * Lexical must resolve to exactly one copy across the app and the rich editor. + * + * The editor registers the app's own nodes (mention-aware links, paste + * handling) into MDXEditor's Lexical instance. If the app and MDXEditor load + * different copies — or different versions of the same package — Lexical's + * `LexicalBuilder` invariant throws during render, the editor falls back to + * its raw-source textarea, and every markdown field in the product silently + * degrades. + * + * That is not hypothetical: root `pnpm.overrides` once force-pinned the + * Lexical family past the range `@mdxeditor/editor` supports, while leaving + * `@lexical/extension` (absent from the override list) on the older line. The + * mismatch shipped and broke the editor everywhere. These assertions fail + * fast on the resolution graph instead of waiting for a render crash. + */ +describe("lexical single copy", () => { + const requireFromUi = createRequire(import.meta.url); + const mdxEditorEntry = requireFromUi.resolve("@mdxeditor/editor"); + const requireFromMdxEditor = createRequire(mdxEditorEntry); + + /** These packages block "./package.json" in exports, so read it off disk. */ + function manifestOf(specifier: string, from: NodeJS.Require): { version: string; dependencies?: Record } { + let dir = dirname(from.resolve(specifier)); + const { root } = parse(dir); + while (true) { + try { + return JSON.parse(readFileSync(join(dir, "package.json"), "utf8")); + } catch { + if (dir === root) throw new Error(`no package.json above ${specifier}`); + dir = dirname(dir); + } + } + } + + function versionOf(specifier: string, from: NodeJS.Require): string { + return manifestOf(specifier, from).version; + } + + it("resolves the same lexical copy for the app and the editor", () => { + expect(requireFromMdxEditor.resolve("lexical")).toBe(requireFromUi.resolve("lexical")); + }); + + it("keeps the app's lexical packages on the editor's version line", () => { + const core = versionOf("lexical", requireFromUi); + // @lexical/link carries the mention-aware LinkNode the app subclasses, so + // a version split here breaks node identity even with one core copy. + expect(versionOf("@lexical/link", requireFromUi)).toBe(core); + expect(versionOf("lexical", requireFromMdxEditor)).toBe(core); + // @lexical/extension owns the LexicalBuilder invariant that throws on a + // mixed graph, and it is reached transitively rather than declared. + expect(versionOf("@lexical/extension", requireFromMdxEditor)).toBe(core); + }); + + it("satisfies the editor's declared lexical range", () => { + const declared = manifestOf("@mdxeditor/editor", requireFromUi).dependencies?.lexical; + const [major, minor] = versionOf("lexical", requireFromUi).split("."); + // A caret range on a 0.x version pins the minor: ^0.48.0 means <0.49.0. + expect(declared).toBe(`^${major}.${minor}.0`); + }); +});