@ai-matrx/diff
The complete AI Matrx diff product: two pure engines plus the React viewer built on them.
-
@ai-matrx/diff/react— the whole UI.DiffViewer(the core everything else composes),TextDiff,DiffReview(per-hunk merge),InlineTextDiff,AnimatedDiffReveal, and the structured entity viewerDiffViewerShellwith its field adapters. No icon library, no UI kit, no editor dependency. -
The engines below are pure logic, zero dependencies, no DOM, nothing at import time:
-
@ai-matrx/diff/text— line-level LCS text diff. One computation emits both the unified (inline) and side-by-side (rows) representations, with word/char-level highlight segments on changed line pairs and a hunk model that turns the diff into an accept/reject merge tool. -
@ai-matrx/diff/structural— generic tree/array structural diff. Deep-diffs two plain objects into a tree of typed change nodes with pluggable per-field identity keys for array matching.
The root (@ai-matrx/diff) re-exports both engines. /react is a separate
subpath so a headless consumer never pays for the UI.
npm install @ai-matrx/diff
React viewer
import { DiffViewer, DiffReview } from "@ai-matrx/diff/react";
// Read-only. Fills its container: render it as a route, a window, a modal, a
// sheet, or any region of a page.
<DiffViewer
original={a}
modified={b}
engine="auto" // "auto" | "light" | "monaco"
language="typescript" // drives auto selection; omit for prose/markdown
originalLabel="Before"
modifiedLabel="After"
defaultView="split" // "split" | "inline" | "highlight"
/>
// Merge. Every hunk is pending / applied / rejected; Apply hands back the text.
<DiffReview original={a} modified={b} onApply={(merged) => save(merged)} />
Views: split (side-by-side), inline (unified), highlight (single-pane —
the new document as flowing prose with only the changed regions tinted; the
reader's view, not a code diff). Unchanged context folds to a few lines around
each change and expands per run; Prev/Next jump between changes; Swap flips
which side is the baseline. The diff is computed ONCE, so resolving, expanding
and navigating never re-run the LCS.
Structured (entity) diff
import { computeDiff } from "@ai-matrx/diff/structural";
import {
DiffViewerShell,
createAdapterRegistry,
TextFieldAdapter,
TagsFieldAdapter,
} from "@ai-matrx/diff/react";
const adapters = createAdapterRegistry();
adapters.register("instructions", TextFieldAdapter);
adapters.register("tags", TagsFieldAdapter);
<DiffViewerShell
diffResult={computeDiff(oldRecord, newRecord)}
oldValue={oldRecord}
newValue={newRecord}
oldLabel="v3"
newLabel="v4"
adapters={adapters}
temporalMetadata={dates} // optional per-side / per-field provenance
/>
Fields with no registered adapter fall back to the package's
DefaultFieldAdapter — never dropped. Provenance rows are explicit about what
they do not know (loading, unavailable) and never substitute a record-wide
timestamp for a missing field date.
Styling — the token contract
Components consume color ONLY through the semantic token vocabulary
(--card, --muted-foreground, --border, --primary, --success,
--destructive, …). Two host steps:
/* Tailwind v4: compile the package's utilities. */
@source "../node_modules/@ai-matrx/diff/dist";
// Fresh app with no token vocabulary of its own — gives every token a
// sensible light/dark value. Apps that already define the AI Matrx tokens
// must NOT import this.
import "@ai-matrx/diff/styles.css";
Red-means-removed / green-means-added is deliberate and non-configurable (it is the universal diff convention, not a brand color); everything else — surfaces, borders, text, fills — follows the host's tokens.
The one injection seam: a heavy (code editor) renderer
The package carries no code-editor dependency. Register one and DiffViewer
routes source code and very large inputs to it:
import { setHeavyDiffRenderer, MONACO_DIFF_EDITOR_OPTIONS } from "@ai-matrx/diff/react";
setHeavyDiffRenderer(MyMonacoDiff); // once, at module scope
With none registered the light engine renders a complete diff — that is the
working default, not a stand-in. Only an explicit engine="monaco" that
cannot be honored is degraded, and it says so in the toolbar and through
onEngineResolved.
Text engine
import { computeTextDiff, applyHunks } from "@ai-matrx/diff/text";
const diff = computeTextDiff("the quick fox\nsecond line", "the slow fox\nsecond line");
diff.stats; // { additions: 0, deletions: 0, modifications: 1, unchanged: 1 }
diff.inline; // unified view: one entry per line per side (removed, then added)
diff.rows; // split view: one aligned row per pair, left/right cells
diff.rows[0].left.segments;
// word-level highlights on the modified pair:
// [{ type: "unchanged", value: "the " }, { type: "removed", value: "quick" },
// { type: "unchanged", value: " fox" }]
diff.whitespaceOnly; // true when only whitespace differs
// Merge tool: accept/reject hunks, get the merged text back.
// Accept-all === modified, accept-none === original — exactly.
applyHunks(original, modified, [0, 2]); // accept hunks 0 and 2, keep the rest old
Behavior notes: removed/added lines in the same change block pair up as modifications
(with intra-line segments when the pair is at least 25% similar); inputs too large for
the dense LCS matrix degrade safely to a whole-block replace instead of OOMing;
ignoreTrailingWhitespace is a display option — the hunk/merge model always diffs raw
lines so merges round-trip byte-exactly. maxLcsCells tightens the matrix cap.
Two further line readings share the same bounded alignment:
import { computeLineChanges, analyzeTextChange } from "@ai-matrx/diff/text";
// A code editor's "+N −M": an edited line is one deletion plus one addition.
const { additions, deletions, lines } = computeLineChanges(before, after);
// A sync conflict's content-loss reading (who holds lines the other lacks,
// lines/chars changed, whitespace/blank-line/trim-only flags, merged segments).
const a = analyzeTextChange(local, remote, { maxLcsCells: 250_000 });
a.modifiedHasContentOriginalLacks; // keeping `local` would lose remote's lines
Above the cap both keep the common prefix and suffix and read the changed middle as one
removed block then one added block, and say degraded: true.
Structural engine
import { computeDiff, filterChangesDeep } from "@ai-matrx/diff/structural";
const result = computeDiff(
{ title: "Old", items: [{ id: "a", qty: 1 }, { id: "b", qty: 2 }] },
{ title: "New", items: [{ id: "b", qty: 2 }, { id: "a", qty: 5 }] },
{ identityKeys: { items: "id" } }, // or (item, index) => string
);
result.hasChanges; // true
result.stats; // { added, removed, modified, unchanged, total }
result.root; // DiffNode tree: title modified; items modified with children:
// "a" modified (qty 1 → 5), "b" reordered
filterChangesDeep(result.root); // prune everything unchanged
Behavior notes: object key order is ignored by default (opt in per path via
orderSensitiveObjectPaths); underscore-prefixed keys are compared by default — schema
contract keys like __kind are data (skipUnderscorePrefix: true is an explicit opt-in
presentation filter); heterogeneous arrays ([{...}, "str"], nested arrays, nulls) never
throw — a mismatched pair is a modified leaf; when a configured identity key is not
unique, the engine falls back to lossless positional matching rather than silently
dropping items; excludePaths takes bare names for root fields or dotted paths for
nested ones.
diffRecordFields(prior, next, rowChange) is the top-level reading of ONE row
write, for a row-history timeline: inserted / deleted / changed fields, a JSON column
as one whole change (equality is === or matching JSON.stringify, so key order
counts), and an "insert" or "delete" row reports every field as inserted or
deleted.
Design
- Pure engines, dual ESM/CJS with matching type declarations, verified with a
packed-tarball install loading every entry point through both
importandrequire. The/reactchunks carry the"use client"boundary; the engine entries deliberately do not, so they stay legal inside a React Server Component. The tarball gate checks both directions. - Both engines run happily during render; every quadratic path is capped and degrades instead of throwing or hanging.
- Strict types throughout:
exactOptionalPropertyTypes,noUncheckedIndexedAccess, zeroany. react/react-domare OPTIONAL peers — only/reactneeds them.
License
MIT
Review sessions
DiffReview requires every hunk to be accepted or rejected before Apply. For a
host-owned session, create it from @ai-matrx/diff/text, pass session and an
onTransition reducer boundary; otherwise pass original and modified for
the source-keyed internal session. Set disabled while the host is pending.