Skip to content

Commit 4926e2c

Browse files
committed
handle children recursively, including keyed children
1 parent 02041a8 commit 4926e2c

File tree

2 files changed

+283
-46
lines changed

2 files changed

+283
-46
lines changed

src/lib/__tests__/diff.children.test.ts

Lines changed: 201 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ describe('diffNodes - Children cases', () => {
180180
const ComponentFn = (props: Props): VNode => ({
181181
kind: 'static',
182182
type: 'div',
183-
props: {},
183+
props,
184184
children: [],
185185
})
186186

@@ -219,24 +219,204 @@ describe('diffNodes - Children cases', () => {
219219
expect(patches).toEqual([])
220220
})
221221

222-
// Text and number nodes
223-
test('handles text node changes')
224-
test('handles number node changes')
225-
test('handles mixed node types (elements, text, numbers)')
226-
227-
// Nested structures
228-
test('recursively diffs nested children')
229-
test('handles deeply nested structure changes')
230-
test('correctly patches nested children props changes')
231-
232-
// Edge cases
233-
test('handles empty children array to non-empty')
234-
test('handles non-empty children array to empty')
235-
test('handles null/undefined children')
236-
test('handles children type changes (text to element)')
237-
238-
// Mixed node kinds
239-
test('handles mix of static and component children')
240-
test('handles mix of regular and memo component children')
241-
test('preserves memoization in nested children')
222+
test('handles multiple children additions and removals', () => {
223+
const oldNode: StaticVNode = {
224+
kind: 'static',
225+
type: 'div',
226+
props: {},
227+
children: [
228+
{
229+
kind: 'static',
230+
type: 'span',
231+
props: { id: '1' },
232+
children: [],
233+
key: '1',
234+
},
235+
{
236+
kind: 'static',
237+
type: 'span',
238+
props: { id: '2' },
239+
children: [],
240+
key: '2',
241+
},
242+
],
243+
}
244+
245+
const newNode: StaticVNode = {
246+
kind: 'static',
247+
type: 'div',
248+
props: {},
249+
children: [
250+
{
251+
kind: 'static',
252+
type: 'span',
253+
props: { id: '3' },
254+
children: [],
255+
key: '3', // New key
256+
},
257+
{
258+
kind: 'static',
259+
type: 'span',
260+
props: { id: '1' },
261+
children: [],
262+
key: '1', // Same key as before
263+
},
264+
],
265+
}
266+
267+
const patches = diffNodes(oldNode, newNode)
268+
269+
// Should generate:
270+
// - REORDER for id:1 span, fromIndex: 0 toIndex: 1
271+
// - REMOVE for id:2 span
272+
// - INSERT for id:3 span
273+
expect(patches).toEqual([
274+
{
275+
type: 'REORDER',
276+
node: oldNode.children[0], // id:1 span
277+
fromIndex: 0,
278+
toIndex: 1,
279+
},
280+
{
281+
type: 'REMOVE',
282+
node: oldNode.children[1], // id:2 span
283+
},
284+
{
285+
type: 'INSERT',
286+
node: newNode.children[0], // id:3 span
287+
index: 0,
288+
},
289+
])
290+
})
291+
292+
// Nested structures - Important for recursive diffing
293+
test('recursively diffs nested children', () => {
294+
const oldNode: StaticVNode = {
295+
kind: 'static',
296+
type: 'div',
297+
props: {},
298+
children: [
299+
{
300+
kind: 'static',
301+
type: 'div',
302+
props: {},
303+
children: [
304+
{
305+
kind: 'static',
306+
type: 'span',
307+
props: { value: 'old' },
308+
children: [],
309+
key: 'nested',
310+
},
311+
],
312+
key: 'parent',
313+
},
314+
],
315+
}
316+
317+
const newNode: StaticVNode = {
318+
kind: 'static',
319+
type: 'div',
320+
props: {},
321+
children: [
322+
{
323+
kind: 'static',
324+
type: 'div',
325+
props: {},
326+
children: [
327+
{
328+
kind: 'static',
329+
type: 'span',
330+
props: { value: 'new' },
331+
children: [],
332+
key: 'nested',
333+
},
334+
],
335+
key: 'parent',
336+
},
337+
],
338+
}
339+
340+
const patches = diffNodes(oldNode, newNode)
341+
342+
expect(patches).toEqual([
343+
{
344+
type: 'PROPS',
345+
node: oldNode.children[0].children[0],
346+
newProps: { value: 'new' },
347+
},
348+
])
349+
})
350+
351+
// Mixed node kinds - Tests how different node types interact
352+
test('handles mix of regular and memo component children', () => {
353+
const ComponentFn = (props: Props): VNode => ({
354+
kind: 'static',
355+
type: 'div',
356+
props,
357+
children: [],
358+
})
359+
360+
const MemoComponentFn = (props: Props): VNode => ({
361+
kind: 'static',
362+
type: 'span',
363+
props,
364+
children: [],
365+
})
366+
367+
const oldNode: StaticVNode = {
368+
kind: 'static',
369+
type: 'div',
370+
props: {},
371+
children: [
372+
{
373+
kind: 'regular',
374+
type: ComponentFn,
375+
props: { value: 'old' },
376+
children: [],
377+
key: 'regular',
378+
},
379+
{
380+
kind: 'memo',
381+
type: MemoComponentFn,
382+
props: { value: 'old' },
383+
children: [],
384+
compare: (prev, next) => prev.value === next.value,
385+
key: 'memo',
386+
},
387+
],
388+
}
389+
390+
const newNode: StaticVNode = {
391+
kind: 'static',
392+
type: 'div',
393+
props: {},
394+
children: [
395+
{
396+
kind: 'regular',
397+
type: ComponentFn,
398+
props: { value: 'new' },
399+
children: [],
400+
key: 'regular',
401+
},
402+
{
403+
kind: 'memo',
404+
type: MemoComponentFn,
405+
props: { value: 'old' }, // Same value, shouldn't update
406+
children: [],
407+
compare: (prev, next) => prev.value === next.value,
408+
key: 'memo',
409+
},
410+
],
411+
}
412+
413+
const patches = diffNodes(oldNode, newNode)
414+
expect(patches).toEqual([
415+
{
416+
type: 'PROPS',
417+
node: oldNode.children[0],
418+
newProps: { value: 'new' },
419+
},
420+
])
421+
})
242422
})

src/lib/diff.ts

Lines changed: 82 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -82,43 +82,100 @@ export function diffNodes(oldNode: VNode, newNode: VNode): Array<Patch> {
8282
const oldChildren = oldNode.children
8383
const newChildren = newNode.children
8484

85-
// maxLength so that we go over all children
86-
const maxLength = Math.max(oldChildren.length, newChildren.length)
85+
// Get all keys and map to index for both old and new children
86+
const oldKeys = new Map<string | number, number>()
87+
const newKeys = new Map<string | number, number>()
88+
89+
// Track which indices we've handled
90+
// When handling children without keys, we need to know which ones we've already handled
91+
const handledIndices = new Set<number>()
92+
93+
// Gather all keys for both old and new children
94+
oldChildren.forEach((child, index) => {
95+
if (child.key) oldKeys.set(child.key, index)
96+
})
8797

88-
for (let i = 0; i < maxLength; i++) {
89-
// Either both exists or one of them doesn't
90-
// We know because of maxLength, either of them will always exist till the end
91-
const oldChild = oldChildren[i]
92-
const newChild = newChildren[i]
98+
newChildren.forEach((child, index) => {
99+
if (child.key) newKeys.set(child.key, index)
100+
})
101+
102+
// Handle keyed nodes first
103+
oldChildren.forEach((oldChild, indexInOldVDom) => {
104+
if (!oldChild.key) return // Skip keyless for now
93105

94-
// If old child doesn't exist, we know that newChild exists
95-
if (!oldChild) {
106+
if (!newKeys.has(oldChild.key)) {
107+
// Key no longer exists -> REMOVE
96108
patches.push({
97-
type: 'INSERT',
98-
node: newChild, // Already VNode which is either static or component node
99-
index: i,
109+
type: 'REMOVE',
110+
node: oldChild,
100111
})
112+
} else {
113+
const indexInNewVDom = newKeys.get(oldChild.key)!
114+
const hasMoved = indexInNewVDom !== indexInOldVDom
115+
if (hasMoved) {
116+
// Position changed -> REORDER
117+
patches.push({
118+
type: 'REORDER',
119+
node: oldChild,
120+
fromIndex: indexInOldVDom,
121+
toIndex: indexInNewVDom,
122+
})
123+
}
124+
125+
const newChild = newChildren[indexInNewVDom]
101126

102-
// No need to do any more work here
103-
// Continue to the next child
104-
continue
127+
// Handle the keyed child recursively using both its old and new child
128+
patches.push(...diffNodes(oldChild, newChild))
129+
130+
handledIndices.add(indexInNewVDom)
105131
}
132+
})
133+
134+
newChildren.forEach((newChild, indexInNewVDom) => {
135+
if (!newChild.key) return // Skip keyless for now
106136

107-
if (!newChild) {
137+
if (!oldKeys.has(newChild.key)) {
138+
// New key -> INSERT
108139
patches.push({
109-
type: 'REMOVE',
110-
node: oldChild, // Already VNode which is either static or component node
140+
type: 'INSERT',
141+
node: newChild,
142+
index: indexInNewVDom,
111143
})
112144

113-
// No need to do any more work here
114-
continue
145+
handledIndices.add(indexInNewVDom)
115146
}
147+
})
148+
149+
// Now handle keyless nodes by position
150+
// But only for indices we haven't handled yet!
151+
const maxLength = Math.max(oldChildren.length, newChildren.length)
152+
153+
for (let index = 0; index < maxLength; index++) {
154+
const hasAlreadyBeenHandled = handledIndices.has(index)
155+
if (hasAlreadyBeenHandled) continue
156+
157+
const oldChild = oldChildren[index]
158+
const newChild = newChildren[index]
116159

117-
// Both exist! Need to diff them
118-
// We know that both are VNode
119-
// diffNodes will return patches for the children
120-
const patch = diffNodes(oldChild, newChild)
121-
patches.push(...patch)
160+
if (!oldChild && newChild) {
161+
// New child for this position (index)
162+
// We know it won't collide with the keyed children because we're always skipping them
163+
patches.push({
164+
type: 'INSERT',
165+
node: newChild,
166+
index: index,
167+
})
168+
} else if (oldChild && !newChild) {
169+
// Old child exists, but new child doesn't
170+
// Remove the old child
171+
patches.push({
172+
type: 'REMOVE',
173+
node: oldChild,
174+
})
175+
} else if (oldChild && newChild) {
176+
// Both exist, diff them recursively
177+
patches.push(...diffNodes(oldChild, newChild))
178+
}
122179
}
123180
}
124181

0 commit comments

Comments
 (0)