/** * `JsonDiff` — a GENERIC, kind-agnostic structural diff of two JSON values, * hand-rolled (no diff library). It recursively walks OBJECT keys and emits one row * per leaf difference: a key present only on one side is `added`/`removed`, a key * whose value differs is `changed`. * * ARRAYS ARE LEAVES: an unequal array (by deep compare) emits exactly ONE `changed` * row at the array's path carrying both whole array values — there are no per-index * rows and no LCS alignment. A container object whose descendants differ emits rows * only for the differing leaves, never for the container itself. * * Rendered as a table: the dotted `path` (e.g. `fixed_kwargs.city`) as code, the * kind as a Badge (removed → danger, added → success, changed → neutral), and the * before/after values as a `JsonTree` for objects/arrays or escaped text for * scalars. Every value renders as React TEXT, so payload markup is escaped and this * is never an HTML sink. * * Each row also carries its `tai-diff-*` tint, but the tint only REINFORCES the * kind — the Badge spells the kind out in words, so a row is never identified by * color alone. */ import { type ReactNode } from 'react'; import { isRecord } from '../guards'; import { Badge } from './badge'; import { JsonTree } from './json-tree'; import { Table, TBody, TD, TH, THead, TR } from './table'; /** One leaf difference between the two JSON values. */ export interface JsonDiffRow { /** The dotted path to the differing key, e.g. `fixed_kwargs.city` (`''` = root). */ readonly path: string; readonly kind: 'added' | 'removed' | 'changed'; /** The value on the LEFT side — set for `removed` and `changed` rows. */ readonly before?: unknown; /** The value on the RIGHT side — set for `added` and `changed` rows. */ readonly after?: unknown; } /** Structural deep-equality over JSON values (arrays compared position-wise). */ function deepEqual(a: unknown, b: unknown): boolean { if (a === b) return true; if (Array.isArray(a) && Array.isArray(b)) { return a.length === b.length && a.every((item, index) => deepEqual(item, b[index])); } if (isRecord(a) && isRecord(b)) { const keys = new Set([...Object.keys(a), ...Object.keys(b)]); for (const key of keys) { if (!deepEqual(a[key], b[key])) return false; } return true; } return false; } function childPath(path: string, key: string): string { return path === '' ? key : `${path}.${key}`; } /** * Recursively collect the leaf differences. Only when BOTH sides are plain objects * does the walk descend by key; anything else (arrays, scalars, a type mismatch) is * compared as a single leaf. */ function collect(path: string, before: unknown, after: unknown, rows: JsonDiffRow[]): void { if (isRecord(before) && isRecord(after)) { const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort((a, b) => a.localeCompare(b), ); for (const key of keys) { const next = childPath(path, key); const inBefore = key in before; const inAfter = key in after; if (inBefore && !inAfter) { rows.push({ path: next, kind: 'removed', before: before[key] }); } else if (!inBefore && inAfter) { rows.push({ path: next, kind: 'added', after: after[key] }); } else { collect(next, before[key], after[key], rows); } } return; } if (!deepEqual(before, after)) { rows.push({ path, kind: 'changed', before, after }); } } /** The pure structural diff — every leaf difference between `before` and `after`. */ export function diffJson(before: unknown, after: unknown): JsonDiffRow[] { const rows: JsonDiffRow[] = []; collect('', before, after, rows); return rows; } const KIND_VARIANT = { added: 'success', removed: 'danger', changed: 'neutral', } as const; /** The row tint for each kind — a reinforcement of the Badge, never the sole cue. */ const KIND_ROW_CLASS = { added: 'tai-diff-added', removed: 'tai-diff-removed', changed: 'tai-diff-changed', } as const; /** * Render one side's value: a `JsonTree` for objects/arrays, escaped text for * scalars. The tree is a scroll region once it overflows, so it is named from * the row's path and its side — a table of rows all announcing "JSON" tells a * reader which pane they are in for none of them. */ function DiffValue({ value, label, }: { readonly value: unknown; readonly label: string; }): ReactNode { if (Array.isArray(value) || isRecord(value)) { return ; } return {JSON.stringify(value)}; } /** The row's path as it reads on screen; the root diff has no path of its own. */ function pathLabel(path: string): string { return path === '' ? '(root)' : path; } const emptyCell = —; export interface JsonDiffProps { readonly before: unknown; readonly after: unknown; } export function JsonDiff({ before, after }: JsonDiffProps): ReactNode { const rows = diffJson(before, after); if (rows.length === 0) { return

No differences between the two values.

; } return ( {rows.map((row, index) => ( ))}
Path Change Before After
{pathLabel(row.path)} {row.kind} {row.kind === 'added' ? ( emptyCell ) : ( )} {row.kind === 'removed' ? ( emptyCell ) : ( )}
); }