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.
Summary
A duplicated copy of
@codemirror/languagein an app's lockfile silently disables JSON/code syntax highlighting inCodeInput/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 theLanguage/syntaxHighlightingfacets, so the highlighter emits no decorations.This is a near-relative of (the
@codemirror/stateduplicate fixed ince21f0380, which threwUnrecognized extension valueand 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):
.cm-content .cm-linetoken spans = 0 (raw monochrome text).ΝΌ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)
@codemirror/languagedescriptors β incl. hoist's~6.12.3β onto a single6.12.3(6.12.4 not yet published). β one copyce21f0380@codemirrorcluster~β^;@codemirror/languagebecomes^6.12.3@codemirror/language@6.12.4published (routine patch)yarn installβ dupyarn-classic keys its lock by descriptor string and resolves each new descriptor to the newest satisfying version. Changing
~6.12.3β^6.12.3created a new descriptor, resolved fresh to6.12.4, while the pre-existing^6.0.xcluster (fromlanguage-data, theme, lang packages) stayed pinned at6.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 letlanguagedrift 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 single6.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 ciin every build kills the masking (highest leverage).npm ciremovesnode_modulesand reifies the committed lockfile exactly β no resolution, no mutation. If snapshot and release both usenpm 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-linenodescript walks thepackagesmap, 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 sharedhoist-dev-utilscomposite action consumed by app repos.3. Range-hygiene check in hoist-react. A published library ships its
package.jsonranges, 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/@lezerranges 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 dedupeis built-in (npm dedupe --dry-runto 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.4unnecessarily. 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
npm cistandardization land inhoist-dev-utils+ app repos.@codemirrorrange adjustments land here in hoist-react.Filed as a findings capture β to revisit after the npm migration.