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 <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip authored and GitHub committed 2026-09-21 15:04:50 -07:00
1 parent 846336e5a0
commit a3749aac46
4 files changed
+59 -12

No files matched your search

+2 -1
View File
@@ -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"
}
}
}
+3
View File
@@ -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"
+10
View File
@@ -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`.
+44 -11
View File
@@ -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", '<div class="greeting">Hello</div>'],
["@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/);
}
});
});