From a3749aac4680a901fa0fe1cc898907887abc9908 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Mon, 21 Sep 2026 15:04:50 -0700 Subject: [PATCH] fix(deps): share Lezer node properties across editor languages fix(deps): share Lezer node properties across editor languages The installed graph gave syntax highlighters @lezer/common 1.5.1 and language parsers 1.5.2. Their independent NodeProp counters collided, so highlighting ordinary code read unrelated metadata as tags and crashed with tags-is-not-iterable. Override @lezer/common to one compatible version in both manifests. Extend the installed-graph check and exercise Python, JavaScript, HTML and SQL highlighting through the editor dependencies. All four examples failed before the override and pass with it. Co-Authored-By: Paperclip --- package.json | 3 +- pnpm-workspace.yaml | 3 + ui/README.md | 10 ++++ ui/src/lib/codemirror-single-instance.test.ts | 55 +++++++++++++++---- 4 files changed, 59 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 69fa2dce14..41feeb179d 100644 --- a/package.json +++ b/package.json @@ -122,7 +122,8 @@ "react": "^19.2.8", "react-dom": "^19.2.8", "@codemirror/state": "^6.7.2", - "@codemirror/view": "^6.43.11" + "@codemirror/view": "^6.43.11", + "@lezer/common": "^1.5.2" } } } diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3518e9086f..1650614afc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -44,3 +44,6 @@ overrides: # range forces every consumer onto one resolution. "@codemirror/state": "^6.7.2" "@codemirror/view": "^6.43.11" + # Parsers and highlighters must share NodeProp IDs from one Lezer instance. + # Mixed copies can interpret unrelated node metadata as highlighting tags. + "@lezer/common": "^1.5.2" diff --git a/ui/README.md b/ui/README.md index b33021d111..d372d07600 100644 --- a/ui/README.md +++ b/ui/README.md @@ -18,3 +18,13 @@ pnpm --filter @paperclipai/ui build-storybook ## Typical use Install the package, then serve or copy the built files from `node_modules/@paperclipai/ui/dist`. + +## Editor dependency identity + +Keep the root and workspace overrides for `@codemirror/state`, +`@codemirror/view`, and `@lezer/common` aligned. CodeMirror requires shared +extension identity, while Lezer parsers and syntax highlighters require shared +`NodeProp` IDs. Multiple Lezer copies can crash code-block highlighting with +`tags is not iterable`. `src/lib/codemirror-single-instance.test.ts` checks the +installed dependency graph and highlights sample code through the editor's real +language dependencies. GitHub Actions owns regeneration of `pnpm-lock.yaml`. diff --git a/ui/src/lib/codemirror-single-instance.test.ts b/ui/src/lib/codemirror-single-instance.test.ts index 570a6fc7e8..7481b47a17 100644 --- a/ui/src/lib/codemirror-single-instance.test.ts +++ b/ui/src/lib/codemirror-single-instance.test.ts @@ -1,21 +1,29 @@ import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs"; +import { createRequire } from "node:module"; import path from "node:path"; import { describe, expect, it } from "vitest"; -// CodeMirror validates extensions with instanceof, so the installed graph -// must contain exactly one physical copy of these packages. Two resolved -// versions ship two module instances and crash the editor at runtime with -// "Unrecognized extension value in extension set" — a failure no unit test -// of the editor itself catches, because each test file sees only one copy. +// CodeMirror validates extensions with instanceof, and Lezer allocates +// NodeProp IDs within each module instance. The installed graph must contain +// one physical copy of their shared primitives. Duplicates can crash extension +// validation or read unrelated syntax metadata as highlighting tags. // The pnpm.overrides entries in the root package.json hold the graph to a // single resolution; this file pins that invariant against the graph the // current install actually resolved, so it holds wherever the tests run — // CI (which installs from the lockfile it regenerates for the PR) and // local checkouts alike. -const SINGLE_INSTANCE_PACKAGES = ["@codemirror/state", "@codemirror/view"]; +const SINGLE_INSTANCE_PACKAGES = [ + ["@codemirror/state", 6], + ["@codemirror/view", 6], + ["@lezer/common", 1], +] as const; const repoRoot = path.resolve(__dirname, "../../.."); const uiRoot = path.resolve(__dirname, "../.."); +const uiRequire = createRequire(path.join(uiRoot, "package.json")); +const editorRequire = createRequire(uiRequire.resolve("@mdxeditor/editor")); +const languagesRequire = createRequire(editorRequire.resolve("@codemirror/language-data")); +const languageRequire = createRequire(languagesRequire.resolve("@codemirror/language")); const workspaceManifest = readFileSync( path.join(repoRoot, "pnpm-workspace.yaml"), "utf8", @@ -92,7 +100,7 @@ function reachableCopies(target: string): string[] { } describe("codemirror single-instance invariant", () => { - for (const pkg of SINGLE_INSTANCE_PACKAGES) { + for (const [pkg, major] of SINGLE_INSTANCE_PACKAGES) { it(`keeps the ${pkg} override in the root manifest and its workspace mirror`, () => { // Removing the override is the only way a second copy can come // back (an override rewrites every dependent's range), so the @@ -100,9 +108,9 @@ describe("codemirror single-instance invariant", () => { expect( rootManifest.pnpm?.overrides?.[pkg], `${pkg} must stay in pnpm.overrides (root package.json); without ` + - "it the graph can resolve two copies and instanceof checks " + - "inside the editor break.", - ).toMatch(/^\^6\./); + "it the graph can resolve two copies and shared editor " + + "primitives no longer have the same identity.", + ).toMatch(new RegExp(`^\\^${major}\\.`)); expect( workspaceManifest, `pnpm-workspace.yaml mirrors the pnpm.overrides block and must ` + @@ -119,11 +127,36 @@ describe("codemirror single-instance invariant", () => { expect( copies, `the installed graph carries multiple physical copies of ${pkg}, ` + - "which break instanceof checks inside the editor. Reinstall " + + "which break shared primitives inside the editor. Reinstall " + "against the current manifests; if the copies persist, fix the " + "pnpm.overrides entry in the root package.json instead of " + "allowing a second copy.", ).toHaveLength(1); }); } + + // Exercise the installed editor graph directly. Separate @lezer/common + // instances allocate colliding NodeProp IDs: the highlighter then reads + // another parser property as style tags and throws "tags is not iterable". + // Using Node resolution also keeps a test bundler from hiding the split. + it.each([ + ["@codemirror/lang-python", "python", "def greet(name):\n return name + '!'\n"], + ["@codemirror/lang-javascript", "javascript", "function greet(name) { return name + '!'; }"], + ["@codemirror/lang-html", "html", '
Hello
'], + ["@codemirror/lang-sql", "sql", "SELECT name FROM greetings WHERE id = 1;"], + ])("highlights code with %s through the editor's installed dependencies", (pkg, factory, code) => { + const language = languagesRequire(pkg)[factory]().language; + const { highlightTree, classHighlighter } = languageRequire("@lezer/highlight"); + const spans: Array<{ from: number; to: number; css: string }> = []; + highlightTree(language.parser.parse(code), classHighlighter, (from: number, to: number, css: string) => { + spans.push({ from, to, css }); + }); + expect(spans.length).toBeGreaterThan(0); + for (const span of spans) { + expect(span.from).toBeGreaterThanOrEqual(0); + expect(span.to).toBeGreaterThan(span.from); + expect(span.to).toBeLessThanOrEqual(code.length); + expect(span.css).toMatch(/\S/); + } + }); });