Skip to content

Commit 2df965a

Browse files
Avoid cloning fragments and arrays of VNodes (#2)
1 parent e30a277 commit 2df965a

File tree

3 files changed

+135
-72
lines changed

3 files changed

+135
-72
lines changed

docs/api.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ Adds props to 'top-level' element and component VNodes in the passed array. Node
1818

1919
The [`options`](#iterationoptions) object can be set to `{ component: true }` or `{ element: true }` to limit iteration to just components or elements respectively.
2020

21-
Eligible VNodes will be passed to the provided callback. The callback should return an object containing props that need to be added to the VNode. The VNode itself will not be changed, it will be cloned using Vue's built-in `cloneVNode` helper.
21+
Eligible VNodes will be passed to the provided callback. The callback should return an object containing props that need to be added to the VNode. The VNode itself will not be changed, it will be cloned using Vue's built-in `cloneVNode()` helper. Any ancestor fragment nodes will be cloned as required.
2222

23-
A new array of VNodes is returned.
23+
The passed array will not be modified, but if no changes were required then the same array may be returned.
2424

2525
### See also
2626

@@ -53,7 +53,7 @@ If the callback returns `null` or `undefined` (or an empty array) then no change
5353

5454
The exact position of the newly inserted nodes within the tree is an implementation detail and should not be relied upon. The current pair of nodes might be in different fragments, or they might already have other nodes between them that are being skipped by the `options`. No guarantees are made about the positions of the inserted nodes relative to other nodes, only that they will be somewhere between the pair passed to the callback.
5555

56-
A new array will be returned and the passed array and its contents should be left unmodified. Any fragment nodes will be cloned as required to avoid mutating the input nodes. The returned array may contain some of the same nodes as the input array, as nodes are not cloned in cases where it can be avoided.
56+
The passed array and its contents will be left unmodified. Any fragment nodes will be cloned as required to avoid mutating the input nodes. The returned array may contain some of the same nodes as the input array, as nodes are not cloned in cases where it can be avoided. If no nodes are inserted then the original array may be returned.
5757

5858
### See also
5959

@@ -399,7 +399,7 @@ The callback will be passed the VNodes in tree order. If any of the children are
399399

400400
If the callback returns `null` or `undefined`, the current node will be left in its current position in the VNode tree. If the callback returns a single VNode, it will replace the original VNode in the tree. If the callback returns an array, all the VNodes in the array will be used to replace the current node. The current VNode can be included in the returned array, allowing for nodes to be added around the current node. An empty array can be used to remove the current VNode.
401401

402-
A new array will be returned and the passed array and its contents should be left unmodified. Any fragment nodes will be cloned as required to avoid mutating the input nodes. The returned array may contain some of the same nodes of the input array, as nodes are not cloned in cases where it can be avoided.
402+
The passed array and its contents will be left unmodified. Any fragment nodes will be cloned as required to avoid mutating the input nodes. The returned array may contain some of the same nodes of the input array, as nodes are not cloned in cases where it can be avoided. If no changes are required then the original array may be returned.
403403

404404
### See also
405405

src/__tests__/vue-vnode-utils.spec.ts

Lines changed: 100 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -224,10 +224,12 @@ describe('addProps', () => {
224224
expect(child.type).toBe('div')
225225
expect(child.props?.class).toBe('red')
226226

227-
// TODO
228-
// expect(startNodes.length).toBe(1)
229-
// expect(startNodes[0]).toBe(startNode)
230-
// expect(startNode.props).toBe(null)
227+
expect(startNodes.length).toBe(1)
228+
expect(startNodes[0]).toBe(fragNode)
229+
expect(fragNode.props).toBe(null)
230+
expect(fragNode.children?.length).toBe(1)
231+
expect((fragNode.children as VNodeArrayChildren)[0]).toBe(divNode)
232+
expect(divNode.props).toBe(null)
231233
})
232234

233235
it('addProps - 3de9', () => {
@@ -380,14 +382,9 @@ describe('addProps', () => {
380382
compareChildren(nullNodes, referenceNodes)
381383
compareChildren(emptyNodes, referenceNodes)
382384

383-
expect(undefinedNodes[0]).toBe(startNodes[0])
384-
expect(undefinedNodes[1]).toBe(startNodes[1])
385-
386-
expect(nullNodes[0]).toBe(startNodes[0])
387-
expect(nullNodes[1]).toBe(startNodes[1])
388-
389-
expect(emptyNodes[0]).toBe(startNodes[0])
390-
expect(emptyNodes[1]).toBe(startNodes[1])
385+
expect(undefinedNodes).toBe(startNodes)
386+
expect(nullNodes).toBe(startNodes)
387+
expect(emptyNodes).toBe(startNodes)
391388
})
392389

393390
it('addProps - a934', () => {
@@ -428,6 +425,33 @@ describe('addProps', () => {
428425
expect((nodes[0] as VNode).props?.class).toBe(undefined)
429426
expect((nodes[1] as VNode).props?.class).toBe('red')
430427
})
428+
429+
it('addProps - 510f', () => {
430+
let count = 0
431+
432+
const spanNode = h('span')
433+
const fragment = [spanNode]
434+
const startNodes = [h('div'), fragment]
435+
436+
const nodes = addProps(startNodes, (vnode) => {
437+
count++
438+
439+
if (vnode.type === 'div') {
440+
return {
441+
class: 'red'
442+
}
443+
}
444+
})
445+
446+
expect(count).toBe(2)
447+
448+
expect(nodes.length).toBe(2)
449+
expect((nodes[0] as VNode).props?.class).toBe('red')
450+
expect(nodes[1]).toBe(fragment)
451+
expect(fragment.length).toBe(1)
452+
expect(fragment[0]).toBe(spanNode)
453+
expect(spanNode.props).toBe(null)
454+
})
431455
})
432456

433457
describe('replaceChildren', () => {
@@ -450,9 +474,9 @@ describe('replaceChildren', () => {
450474
expect(count).toBe(1)
451475
expect(Array.isArray(nodes)).toBe(true)
452476
expect(nodes).toHaveLength(1)
477+
expect(nodes).toBe(startNodes)
453478

454479
compareChildren(startNodes, [h('div')])
455-
compareChildren(nodes, [h('div')])
456480
})
457481

458482
it('replaceChildren - 7c8a', () => {
@@ -476,6 +500,7 @@ describe('replaceChildren', () => {
476500
expect(nodes).toHaveLength(0)
477501

478502
compareChildren(startNodes, [h('div')])
503+
expect(startNodes[0]).toBe(startNode)
479504
})
480505

481506
it('replaceChildren - 1d16', () => {
@@ -601,6 +626,37 @@ describe('replaceChildren', () => {
601626
compareChildren(startNodes, [h('div'), 'Text', [h('span'), 'More text']])
602627
compareChildren(nodes, [h('div'), '(Text)', [h('span'), '(More text)']])
603628
})
629+
630+
it('replaceChildren - e076', () => {
631+
let count = 0
632+
633+
const startNodes = ['Text']
634+
635+
const nodes = replaceChildren(startNodes, () => {
636+
count++
637+
})
638+
639+
expect(count).toBe(1)
640+
expect(Array.isArray(nodes)).toBe(true)
641+
expect(nodes).toHaveLength(1)
642+
expect(isVNode(nodes[0])).toBe(true)
643+
644+
expect(startNodes).toHaveLength(1)
645+
expect(startNodes[0]).toBe('Text')
646+
647+
// Do the same thing with a text VNode
648+
const startVNodes = [createTextVNode('Text')]
649+
650+
count = 0
651+
652+
const nodesOut = replaceChildren(startVNodes, () => {
653+
count++
654+
})
655+
656+
expect(count).toBe(1)
657+
expect(nodesOut).toBe(startVNodes)
658+
expect(nodesOut).toHaveLength(1)
659+
})
604660
})
605661

606662
describe('betweenChildren', () => {
@@ -615,17 +671,11 @@ describe('betweenChildren', () => {
615671
})
616672

617673
expect(count).toBe(0)
618-
expect(Array.isArray(nodes)).toBe(true)
619-
expect(nodes.length).toBe(1)
620-
621-
const node = nodes[0] as VNode
674+
expect(nodes).toBe(startNodes)
622675

623-
expect(isElement(node)).toBe(true)
624-
expect(node.type).toBe('div')
625-
expect(node.props).toBe(null)
626-
627-
expect(startNodes.length).toBe(1)
676+
expect(startNodes).toHaveLength(1)
628677
expect(startNodes[0]).toBe(startNode)
678+
expect(startNode.type).toBe('div')
629679
expect(startNode.props).toBe(null)
630680
})
631681

@@ -649,10 +699,8 @@ describe('betweenChildren', () => {
649699
})
650700

651701
expect(count).toBe(1)
652-
expect(Array.isArray(nodes)).toBe(true)
653-
expect(nodes.length).toBe(2)
702+
expect(nodes).toBe(startNodes)
654703

655-
compareChildren(nodes, [h('div'), h('span')])
656704
compareChildren(startNodes, [h('div'), h('span')])
657705
})
658706

@@ -677,10 +725,8 @@ describe('betweenChildren', () => {
677725
})
678726

679727
expect(count).toBe(1)
680-
expect(Array.isArray(nodes)).toBe(true)
681-
expect(nodes.length).toBe(2)
728+
expect(nodes).toBe(startNodes)
682729

683-
compareChildren(nodes, [h('div'), h('span')])
684730
compareChildren(startNodes, [h('div'), h('span')])
685731
})
686732

@@ -1171,6 +1217,32 @@ describe('betweenChildren', () => {
11711217
]
11721218
])
11731219
})
1220+
1221+
it('betweenChildren - 2bea', () => {
1222+
let count = 0
1223+
1224+
const startNodes = [['Text'], [createTextVNode('Text')]]
1225+
1226+
const nodes = betweenChildren(startNodes, (before, after) => {
1227+
count++
1228+
1229+
expect(isVNode(before)).toBe(true)
1230+
expect(isVNode(after)).toBe(true)
1231+
1232+
expect(getText(before)).toBe('Text')
1233+
expect(getText(after)).toBe('Text')
1234+
})
1235+
1236+
expect(count).toBe(1)
1237+
1238+
expect(nodes).toHaveLength(2)
1239+
expect(Array.isArray(nodes[0])).toBe(true)
1240+
expect(nodes[0]).toHaveLength(1)
1241+
expect(isVNode((nodes[0] as VNodeArrayChildren)[0])).toBe(true)
1242+
expect(nodes[1]).toBe(startNodes[1])
1243+
1244+
expect(startNodes[0][0]).toBe('Text')
1245+
})
11741246
})
11751247

11761248
describe('someChild', () => {

src/vue-vnode-utils.ts

Lines changed: 31 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -164,18 +164,6 @@ const getFragmentChildren = (fragmentVNode: VNode | VNodeArrayChildren): VNodeAr
164164
return []
165165
}
166166

167-
const setFragmentChildren = (fragment: VNode | VNodeArrayChildren, children: VNodeArrayChildren): (VNode | VNodeArrayChildren) => {
168-
if (Array.isArray(fragment)) {
169-
return children
170-
}
171-
172-
const newNode = cloneVNode(fragment)
173-
174-
newNode.children = children
175-
176-
return newNode
177-
}
178-
179167
export type IterationOptions = {
180168
element?: boolean
181169
component?: boolean
@@ -232,23 +220,7 @@ export const addProps = (
232220
checkArguments('addProps', [children, callback, options], ['array', 'function', 'object'])
233221
}
234222

235-
return children.map(child => addPropsToChild(child, callback, options))
236-
}
237-
238-
const addPropsToChild = (
239-
child: VNodeChild,
240-
callback: (vnode: VNode) => (Record<string, unknown> | null | void),
241-
options: IterationOptions
242-
): VNodeChild => {
243-
if (isFragment(child)) {
244-
const newChildren = addProps(getFragmentChildren(child), callback, options)
245-
246-
return setFragmentChildren(child, newChildren)
247-
}
248-
249-
const vnode = promoteToVNode(child, options)
250-
251-
if (vnode) {
223+
return replaceChildren(children, (vnode) => {
252224
const props = callback(vnode)
253225

254226
if (DEV) {
@@ -262,9 +234,7 @@ const addPropsToChild = (
262234
if (props && !isEmptyObject(props)) {
263235
return cloneVNode(vnode, props)
264236
}
265-
}
266-
267-
return child
237+
}, options)
268238
}
269239

270240
export const replaceChildren = (
@@ -276,13 +246,30 @@ export const replaceChildren = (
276246
checkArguments('replaceChildren', [children, callback, options], ['array', 'function', 'object'])
277247
}
278248

279-
const nc: VNodeArrayChildren = []
249+
let nc: VNodeArrayChildren | null = null
250+
251+
for (let index = 0; index < children.length; ++index) {
252+
const child = children[index]
280253

281-
for (const child of children) {
282254
if (isFragment(child)) {
283-
const newChildren = replaceChildren(getFragmentChildren(child), callback, options)
255+
const oldFragmentChildren = getFragmentChildren(child)
256+
const newFragmentChildren = replaceChildren(oldFragmentChildren, callback, options)
257+
258+
let newChild: VNodeChild = child
259+
260+
if (oldFragmentChildren !== newFragmentChildren) {
261+
nc ??= children.slice(0, index)
284262

285-
nc.push(setFragmentChildren(child, newChildren))
263+
if (Array.isArray(child)) {
264+
newChild = newFragmentChildren
265+
} else {
266+
newChild = cloneVNode(child)
267+
268+
newChild.children = newFragmentChildren
269+
}
270+
}
271+
272+
nc && nc.push(newChild)
286273
} else {
287274
const vnode = promoteToVNode(child, options)
288275

@@ -297,18 +284,22 @@ export const replaceChildren = (
297284
}
298285
}
299286

287+
if (newNodes !== child) {
288+
nc ??= children.slice(0, index)
289+
}
290+
300291
if (Array.isArray(newNodes)) {
301-
nc.push(...newNodes)
292+
nc && nc.push(...newNodes)
302293
} else {
303-
nc.push(newNodes)
294+
nc && nc.push(newNodes)
304295
}
305296
} else {
306-
nc.push(child)
297+
nc && nc.push(child)
307298
}
308299
}
309300
}
310301

311-
return nc
302+
return nc ?? children
312303
}
313304

314305
export const betweenChildren = (

0 commit comments

Comments
 (0)