From c8a136fb02394e18eaa3161dc6e46a482cfbc39b Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Thu, 27 Aug 2026 09:26:42 -0700 Subject: [PATCH] Restore a single Lexical copy so the rich editor renders (#12317) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - People write almost every issue, goal, document, and chat message through the rich markdown editor, which wraps `@mdxeditor/editor` on top of Lexical > - On `master` that editor renders its raw-source textarea instead. It shows "Rich editor unavailable for this markdown" on every field, for all content, including empty content > - Lexical throws at render time because the project holds two copies of `LexicalBuilder`. The root `pnpm.overrides` block forced the Lexical family past the version range the editor supports, and it missed the packages that are reached transitively > - Every markdown surface in the product degrades to plain text editing, so this is a full loss of a core authoring feature and not a cosmetic problem > - This pull request removes the Lexical entries from `pnpm.overrides`, pins the app to Lexical 0.48.0, and adds a test that guards the resolution graph > - The benefit is that the rich editor renders again, and a future override or bump that splits Lexical fails in CI instead of in the browser ## Linked Issues or Issue Description No public issue exists. The problem is described below with the bug report template. **What happened?** The rich markdown editor falls back to its raw-source textarea on every markdown field. The header reads "Rich editor unavailable for this markdown. Showing raw source instead." The fallback appears for all content, including empty content. React catches an error during the first render of the editor. The cause is dependency resolution. The root `package.json` `pnpm.overrides` block pinned the Lexical family to `0.49.0`. `@mdxeditor/editor@4.2.1` declares `lexical: "^0.48.0"`. A caret range on a `0.x` version pins the minor, so `^0.48.0` means `>=0.48.0 <0.49.0`. The override therefore pushed the editor past its only supported line. Lexical 0.49.0 also carries breaking `$config()` node changes. The override list was also incomplete. `@lexical/extension`, `@lexical/history`, and `@lexical/internal` are reached transitively and were never listed. Those packages stayed on 0.48 while the listed packages moved to 0.49. The graph mixed the two lines, and the built browser bundle carried two `Symbol.for("@lexical/extension/LexicalBuilder")` registrations. Commit 04432f82c (#10728) introduced this. It was a Dependabot bump from 0.46.0 to 0.49.0. **Expected behavior** The rich editor renders a live WYSIWYG surface on every markdown field. The raw-source fallback stays reserved for real markdown parse failures. **Steps to reproduce** 1. Check out `master` and run `pnpm install`. 2. Start the app and open any issue, goal, or document. 3. Look at any markdown field, for example the issue description. 4. The field shows the fallback banner and a plain textarea. The rich toolbar is absent. You can also see the split without the browser: ``` pnpm --filter @paperclipai/ui build grep -ohE 'Symbol\.for\("@lexical/extension/LexicalBuilder"\)' ui/dist/assets/index-*.js | wc -l ``` On `master` this prints `2`. A correct graph prints `1`. **Paperclip version or commit** `master` at 1ba7b2cc7. The regression entered at 04432f82c (#10728). **Relevant logs or output** ``` LexicalBuilder.fromEditor: The given editor was created with LexicalBuilder 0.48.0+dev.esm but this version is 0.49.0+dev.esm. A project should have exactly one copy of LexicalBuilder ``` React reports the error in ``. `MarkdownEditorRichErrorBoundary` catches it and shows the raw-source fallback. ## What Changed - Removed the Lexical entries from `pnpm.overrides` in the root `package.json`. This drops `lexical`, `@lexical/clipboard`, `@lexical/link`, `@lexical/list`, `@lexical/markdown`, `@lexical/plain-text`, `@lexical/react`, `@lexical/rich-text`, `@lexical/selection`, and `@lexical/utils`. The `rollup`, `react`, and `react-dom` overrides stay as they are. - Pinned `lexical` and `@lexical/link` to `0.48.0` in `ui/package.json`. The app subclasses `LinkNode` for mention-aware links and registers it into the Lexical instance that MDXEditor owns, so node identity needs one copy. `@lexical/link@0.48.0` pins `lexical` at `0.48.0` exactly. - Left `pnpm-lock.yaml` out of this commit on purpose. The `policy` job blocks manual lockfile edits, regenerates the lockfile from the manifests, and uploads it for the downstream `pnpm install --frozen-lockfile` jobs. I did regenerate the lockfile locally to verify the result. That local lockfile touched only Lexical and MDXEditor entries and held no Lexical 0.49 reference. - Added `ui/src/lib/lexical-single-copy.test.ts`. It asserts the app and the editor resolve the same `lexical` file, that `@lexical/link` and the transitively reached `@lexical/extension` sit on that version, and that the version satisfies the range the editor declares. `ui` is the only workspace package that declares a Lexical dependency, so nothing else changes. ### Why the overrides can go #9180 added the Lexical overrides for a good reason. At that time the app used Lexical 0.46.0 and `@mdxeditor/editor` declared `lexical@^0.35.0`. The two ranges could not meet, so an override was the only way to force one copy. That condition is gone. `@mdxeditor/editor@4.2.1` now declares `lexical: "^0.48.0"`. When the app also declares `0.48.0`, pnpm resolves one copy on its own and the override has nothing left to do. The override is now a liability. It can only pin the packages it lists by name. `@lexical/extension` did not exist when the list was written, so the list never covered it, and the list silently stopped covering the whole family. Natural resolution has no such blind spot, because every Lexical package carries its own exact pin on the core. The existing `lexical` alias in `ui/vite.config.ts` and `ui/vitest.config.ts` stays. It still resolves at 0.48.0. Note that the alias covers only the `lexical` core, which is why it did not prevent this bug and why the new test reads the resolution graph with `createRequire` instead of importing Lexical. ### Related pull requests - #10728 — the Dependabot bump that introduced the regression. Superseded by this change. - #10724 — the companion core bump merged the same day. - #9180 — added the overrides block that this change removes. See the explanation above. - #9179 — an older, still-open 0.46.0 bump. It is stale and this change does not depend on it. I found no open pull request that already fixes this problem. ## Verification All commands ran from the repository root. - `npx vitest run ui/src/lib/lexical-single-copy.test.ts` — 3 passed. - Guard proven against the broken tree. I restored the pre-fix `package.json`, `ui/package.json`, and `pnpm-lock.yaml`, ran `pnpm install`, and re-ran the guard. It failed 2 of 3: `expected '0.48.0' to be '0.49.0'` for `@lexical/extension`, and `expected '^0.48.0' to be '^0.49.0'` for the declared range. I then restored the fix and `pnpm install` reproduced the committed lockfile byte for byte. - `pnpm --filter @paperclipai/ui typecheck` — clean. The Lexical API calls in the app still compile on 0.48. - `npx vitest run ui/src/lib/mention-deletion.test.ts ui/src/lib/mention-aware-link-node.test.ts ui/src/components/MarkdownEditor.test.tsx` — 42 passed. - `pnpm --filter @paperclipai/ui build`, then the `grep` above — the bundle now holds 1 `LexicalBuilder` registration. It held 2 before this change. - Full `ui` project: `npx vitest run --project '@paperclipai/ui'` — 4426 of 4431 passed. I re-ran the 5 failures in isolation. Four passed, so they were load-induced 5 second timeouts in `CompanyEnvironments.test.tsx` and `IssuesList.test.tsx`. The last one, `OnboardingWizard.test.tsx > renders instead of throwing when the browser denies storage access`, also fails on clean `master` and is unrelated to this change. - Real render probe. I wrote a throwaway test that renders `MarkdownEditor` against the real `@mdxeditor/editor` with no mock. Before this change it produced the fallback banner and the `LexicalBuilder` error. After this change the editor mounts a live `contenteditable` surface and logs no error. The probe was temporary and is not part of this diff. ## Risks - This pins the app's Lexical to the line the editor supports rather than to the newest release. A future Lexical upgrade must wait for an `@mdxeditor/editor` release that supports it. I checked published `@mdxeditor/editor` through 4.2.3 and none supports Lexical 0.49, so fixing forward is not possible today. The new test enforces that ordering, so an upgrade attempt fails in CI with a clear reason. - Dependabot will offer the Lexical 0.49 bump again. The new test will fail that pull request. That is the intended behavior. A reviewer should keep the bump closed until MDXEditor moves first. - 04432f82c was a routine version bump and not a security fix, so this change reintroduces no known advisory. - The change is limited to dependency resolution and one new test. It alters no application code. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used Claude (Anthropic), Claude Opus, agentic tool use via Claude Code. - Provider and model: Claude (Anthropic), Claude Opus - Capability: agentic tool use through Claude Code, with repository search, file edits, dependency installation, test and build runs ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes (no document in the repository states a Lexical version or pinning policy, so the reasoning is recorded in the new test's header comment) - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- package.json | 12 +---- ui/package.json | 4 +- ui/src/lib/lexical-single-copy.test.ts | 66 ++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 13 deletions(-) create mode 100644 ui/src/lib/lexical-single-copy.test.ts 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`); + }); +});