Skip to content

Instantly share code, notes, and snippets.

@audinue
Last active April 10, 2025 19:22
Show Gist options
  • Save audinue/0b294ebb1d22bf60ba6c3bb46ba353ed to your computer and use it in GitHub Desktop.
Save audinue/0b294ebb1d22bf60ba6c3bb46ba353ed to your computer and use it in GitHub Desktop.
Agnostic keyed diff
export const tests = [
// 0
{
previous: ['A', 'B'],
next: ['A', 'B', 'C'],
expected: [
{ tag: 'skip', index: 0 },
{ tag: 'skip', index: 1 },
{ tag: 'insert', index: 2 },
]
},
// 1
{
previous: ['A', 'B'],
next: ['C', 'A', 'B'],
expected: [
{ tag: 'insert', index: 0 },
{ tag: 'skip', index: 1 },
{ tag: 'skip', index: 2 },
]
},
// 2
{
previous: ['A', 'B'],
next: ['A', 'C', 'B'],
expected: [
{ tag: 'skip', index: 0 },
{ tag: 'insert', index: 1 },
{ tag: 'skip', index: 2 },
]
},
// 3
{
previous: ['A', 'B', 'C'],
next: ['A', 'B'],
expected: [
{ tag: 'skip', index: 0 },
{ tag: 'skip', index: 1 },
{ tag: 'remove', index: 2 },
]
},
// 4
{
previous: ['A', 'B', 'C'],
next: ['B', 'C'],
expected: [
{ tag: 'remove', index: 0 },
{ tag: 'skip', index: 0 },
{ tag: 'skip', index: 1 },
]
},
// 5
{
previous: ['A', 'B', 'C'],
next: ['C', 'A', 'B'],
expected: [
{ tag: 'move', from: 2, to: 0 },
{ tag: 'skip', index: 1 },
{ tag: 'skip', index: 2 },
]
},
// 6
{
previous: ['A', 'B', 'C'],
next: ['C', 'B', 'A'],
expected: [
{ tag: 'move', from: 2, to: 0 },
{ tag: 'move', from: 2, to: 1 },
{ tag: 'skip', index: 2 },
]
},
// 7
{
previous: ['A', 'B', 'C', 'D'],
next: ['D', 'B', 'C', 'A'],
expected: [
{ tag: 'move', from: 3, to: 0 },
{ tag: 'move', from: 2, to: 1 },
{ tag: 'move', from: 3, to: 2 },
{ tag: 'skip', index: 3 },
]
},
]
import { Window } from 'npm:happy-dom'
import { diff } from './diff.mjs'
import { tests } from './diff-data.mjs'
import { identity } from './diff-util.mjs'
const doc = new Window().document
const createList = (array) => {
const list = doc.createElement('ul')
for (const value of array) {
const li = doc.createElement('li')
li.textContent = value
list.append(li)
}
return list
}
const patchList = (previous, next, key, list) => {
const changes = diff(previous, next, identity)
for (const { tag, index, from, to } of changes) {
switch (tag) {
case 'insert':
const li = doc.createElement('li')
li.textContent = next[index]
list.insertBefore(li, list.children[index])
break
case 'remove':
list.removeChild(list.children[index])
break
case 'move':
list.insertBefore(
list.children[from],
list.children[to]
)
break
}
}
}
for (const [index, { previous, next }] of tests.entries()) {
const previousList = createList(previous)
const nextList = createList(next)
patchList(previous, next, identity, previousList)
if (previousList.innerHTML !== nextList.innerHTML) {
console.log('INDEX', index)
console.log('previous', previous)
console.log('next', next)
console.log('previousList', previousList.innerHTML)
console.log('nextList', nextList.innerHTML)
break
}
}
import { diff } from './diff.mjs'
import { tests } from './diff-data.mjs'
import { identity } from './diff-util.mjs'
for (const [index, { previous, next, expected }] of tests.entries()) {
const actual = diff(previous, next, identity)
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
console.log('INDEX', index)
console.log('previous', previous)
console.log('next', next)
console.log('actual', actual)
console.log('expected', expected)
break
}
}
export const identity = (value) => {
return value
}
/**
* Computes a list of changes needed to transform `previous` into `next`.
* It generates a sequence of operations: insert, remove, move, or skip.
*
* @template T
* @param {T[]} previous - The original list of items before updates.
* @param {T[]} next - The new desired list of items after updates.
* @param {(value: T) => any} key - A function to extract a unique, comparable key from each item.
* @returns {Array<{tag: 'insert' | 'remove' | 'skip', index: number} | {tag: 'move', from: number, to: number}>}
*/
export const diff = (previous, next, key) => {
const results = []
// Create a working copy of the previous list so we can mutate it during diffing
let current = [...previous]
// Iterate through each index of the target `next` array
for (let i = 0; i < next.length; i++) {
if (i < current.length) {
// Extract keys for both the current and desired item at this index
const prevKey = key(current[i])
const nextKey = key(next[i])
if (prevKey !== nextKey) {
// `next[i]` does not match `current[i]`, so we need to investigate
// Look for the item with `nextKey` somewhere else in the `current` list
let foundIndex = -1
for (let j = 0; j < current.length; j++) {
if (key(current[j]) === nextKey) {
foundIndex = j
break
}
}
if (foundIndex !== -1) {
// If the item exists in `current`, check if the `prevKey` is still needed in `next`
let foundInNext = false
for (const item of next) {
if (key(item) === prevKey) {
foundInNext = true
break
}
}
if (foundInNext) {
// Both items exist, but in wrong positions → perform a move
results.push({ tag: 'move', from: foundIndex, to: i })
// Apply the move in the working list to keep it in sync
current.splice(foundIndex, 1)
current.splice(i, 0, next[i])
} else {
// The old item is no longer needed → remove it
results.push({ tag: 'remove', index: i })
current.splice(i, 1)
// Decrement `i` to retry this index with the new current item
i--
}
} else {
// The desired item is new → insert it
results.push({ tag: 'insert', index: i })
current.splice(i, 0, next[i])
}
} else {
// The current item is already correct → skip
results.push({ tag: 'skip', index: i })
}
} else {
// We’ve reached the end of the `current` list but still have more items in `next`
// → insert new items at the end
results.push({ tag: 'insert', index: i })
current.push(next[i])
}
}
// After processing all items in `next`, if `current` is longer, remove extras
for (let i = next.length; i < current.length; i++) {
results.push({ tag: 'remove', index: i })
}
return results
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment