Skip to content

CodeInput: duplicate @codemirror/language silently disables syntax highlighting; add singleton-dup guard (npm)Β #4459

Description

@amcclain

Summary

A duplicated copy of @codemirror/language in an app's lockfile silently disables JSON/code syntax highlighting in CodeInput / JsonInput, while leaving the editor otherwise fully functional. The editor renders, edits, gutters and line numbers all work β€” only the color layer is gone. CodeMirror 6's core/language packages must resolve to a single shared instance; when two copies load, the parse layer and the highlight extension key off different module instances of the Language / syntaxHighlighting facets, so the highlighter emits no decorations.

This is a near-relative of (the @codemirror/state duplicate fixed in ce21f0380, which threw Unrecognized extension value and blanked the editor) β€” same disease, different package, much subtler symptom.

How it surfaced

Observed in Toolbox: the versioned release (built with a frozen lockfile against hoist-react 86.2.0) had no JSON color coding in the admin config editor, while the snapshot deploy (same hoist-react) highlighted correctly.

Confirmed live (same config, both deploys):

  • Release: .cm-content .cm-line token spans = 0 (raw monochrome text).
  • Snapshot: 17 token spans with generated highlight classes (ΝΌ16/ΝΌ17/ΝΌ1a) and real colors.

The 0-span result rules out a CSS/theme problem (that would leave spans present but uncolored) β€” the highlight extension produced nothing, the duplicate-instance signature.

Root cause β€” how the dup formed

Timeline (yarn-classic mechanics)
When Event
Earlier release App locks all @codemirror/language descriptors β€” incl. hoist's ~6.12.3 β€” onto a single 6.12.3 (6.12.4 not yet published). βœ… one copy
ce21f0380 hoist-react widens the @codemirror cluster ~ β†’ ^; @codemirror/language becomes ^6.12.3
Same window @codemirror/language@6.12.4 published (routine patch)
App upgrade to 86.2.0 yarn install β†’ dup

yarn-classic keys its lock by descriptor string and resolves each new descriptor to the newest satisfying version. Changing ~6.12.3 β†’ ^6.12.3 created a new descriptor, resolved fresh to 6.12.4, while the pre-existing ^6.0.x cluster (from language-data, theme, lang packages) stayed pinned at 6.12.3 β€” yarn's sticky lock leaves already-satisfied entries alone. One descriptor floated up, its siblings didn't β†’ two copies. (The tilde had been accidentally keeping it deduped; widening it β€” a fix aimed at @codemirror/state β€” is what let language drift from its cluster.)

Why it was invisible: the snapshot build runs yarn upgrade (re-resolves + dedupes the whole tree on the fly), so dev built from a deduped lock while the frozen release shipped the duped one. Dev looked fine; prod didn't. The masking is the real reason this reached production.

Immediate workaround

Dedupe the app lockfile and re-release: npx yarn-deduplicate --packages @codemirror/language (yarn) collapses the cluster onto a single 6.12.4. (Applied to Toolbox; re-release in progress.)

Proposed remediation β€” centered on the planned npm migration

We expect to move both hoist-react and apps to npm shortly. npm improves this in three ways, and the remediation should be designed for that world:

1. npm ci in every build kills the masking (highest leverage). npm ci removes node_modules and reifies the committed lockfile exactly β€” no resolution, no mutation. If snapshot and release both use npm ci, they build from a byte-identical lock and the dev/prod divergence that hid this cannot occur. Caveat: pull fresh snapshot deps surgically (npm install @xh/hoist@<snap>), not via a blanket re-resolve, and keep "what's tested == what's locked == what ships."

2. App-side singleton-dup check (PR + build gate). package-lock.json (lockfileVersion 3) is plain JSON, so the check needs no third-party tool β€” a ~15-line node script walks the packages map, groups by package name, and exits non-zero if any watched package (@codemirror/*, @lezer/*, react, react-dom, mobx, …) resolves to >1 version. Runs on the committed lock (deterministic, install-free). This is the guard that would have caught the incident; package this as a shared hoist-dev-utils composite action consumed by app repos.

3. Range-hygiene check in hoist-react. A published library ships its package.json ranges, not its lockfile, and clean installs almost always dedupe β€” so a lockfile dup-check here would give false comfort. The valuable library-side guard is a CI step asserting hoist-react's declared @codemirror/@lezer ranges resolve to single versions in a fresh install, catching a future tilde/caret slip before publish (i.e. before it can split a downstream app's incrementally-evolved lock).

4. Native remediation: on npm, npm dedupe is built-in (npm dedupe --dry-run to preview) β€” no third-party tooling needed.

Likelihood

Lower on npm: its resolver prefers reusing an in-tree version that already satisfies a changed range, rather than yarn-classic's "new descriptor β†’ newest" jump that pulled in 6.12.4 unnecessarily. So the precise trigger is less likely to fire β€” the check is a true backstop, cheap enough to be worth it (especially as a PR gate).

Scope / where pieces land

  • This issue lives in hoist-react for visibility; items 2 and the npm ci standardization land in hoist-dev-utils + app repos.
  • Item 3 (range-hygiene) and any @codemirror range adjustments land here in hoist-react.

Filed as a findings capture β€” to revisit after the npm migration.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions