Skip to content

Commit abc6dce

Browse files
committed
fix(runtime-vapor): extend the dense v-for move plan to the suffix bound
1 parent 082ea29 commit abc6dce

4 files changed

Lines changed: 165 additions & 138 deletions

File tree

packages/runtime-vapor/__tests__/for.spec.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2354,6 +2354,16 @@ describe('createFor', () => {
23542354
).toBe(5)
23552355
})
23562356

2357+
// a removal can leave an in-place match to the right of the moved row:
2358+
// the plan has to cover it, or the row is judged already in order
2359+
test('moving a reused row before a stationary one while removing', async () => {
2360+
expect(await countMoves([0, 1, 2], [2, 1])).toBe(1)
2361+
})
2362+
2363+
test('keeps a reused run in place when the row after it moves', async () => {
2364+
expect(await countMoves([0, 1, 2, 3, 4], [3, 4, 2])).toBe(1)
2365+
})
2366+
23572367
test('reordering past a row that renders nothing keeps rows anchored', async () => {
23582368
const list = ref([1, 2, 3])
23592369
const { host } = define(() =>

packages/runtime-vapor/src/apiCreateFor.ts

Lines changed: 146 additions & 135 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,13 @@ import {
1313
toReadonly,
1414
watch,
1515
} from '@vue/reactivity'
16-
import { getSequence, isArray, isObject, isString } from '@vue/shared'
16+
import {
17+
EMPTY_ARR,
18+
getSequence,
19+
isArray,
20+
isObject,
21+
isString,
22+
} from '@vue/shared'
1723
import { createComment, createTextNode } from './dom/node'
1824
import {
1925
type Block,
@@ -181,9 +187,10 @@ export const createFor = (
181187
if (getKey) {
182188
newKeys = new Array(newLength)
183189
for (let i = 0; i < newLength; i++) {
190+
const value = getItemValue(source, i)
184191
newKeys[i] = sourceKeys
185-
? getKey(getItemValue(source, i), sourceKeys[i], i)
186-
: getKey(getItemValue(source, i), i, undefined)
192+
? getKey(value, sourceKeys[i], i)
193+
: getKey(value, i, undefined)
187194
}
188195
}
189196

@@ -275,15 +282,11 @@ export const createFor = (
275282
const queuedIndices: number[] = []
276283
const oldKeyIndexMap = new Map<any, number>()
277284

278-
// in-place matches, remembered so a dense reorder can pull them into
279-
// the move plan (they sit at the same index in both lists)
280-
let stationaryIndices: number[] | undefined
281285
for (let i = 0; i < e1; i++) {
282286
const currentKey = newKeys![i]
283287
const oldBlock = oldBlocks[i]
284288
if (oldBlock.key === currentKey) {
285289
updateAt((newBlocks[i] = oldBlock), source, i)
286-
;(stationaryIndices ||= []).push(i)
287290
} else {
288291
queuedIndices.push(i)
289292
oldKeyIndexMap.set(oldBlock.key, i)
@@ -304,16 +307,16 @@ export const createFor = (
304307
// marks a fresh mount). The bounds filter below zeroes reuses that may
305308
// not stay put; the apply pass tells move from mount by
306309
// `newBlocks[index]`, which the reuse pass has already filled in.
307-
const sources: number[] = new Array(queuedLength)
310+
let sources: number[] = EMPTY_ARR as unknown as number[]
308311
let mountCounter = 0
309312

310313
if (oldKeyIndexMap.size === 0) {
311-
// pure append/replace: nothing to pair up. Plain loop over fill():
312-
// the inlined monomorphic store beats the generic builtin on a
313-
// freshly allocated (holey) array.
314-
for (let q = 0; q < queuedLength; q++) sources[q] = 0
314+
// pure append/replace: nothing to pair up, so every queued index is
315+
// a mount, the planner below is skipped and `sources` is never
316+
// read - don't build it
315317
mountCounter = queuedLength
316318
} else {
319+
sources = new Array(queuedLength)
317320
for (let q = queuedLength - 1; q >= 0; q--) {
318321
const index = queuedIndices[q]
319322
const key = newKeys![index]
@@ -337,114 +340,28 @@ export const createFor = (
337340
if (useFastRemove && frag.resetListeners) {
338341
for (const fn of frag.resetListeners) fn()
339342
}
340-
for (const leftoverIndex of oldKeyIndexMap.values()) {
341-
unmount(
342-
oldBlocks[leftoverIndex],
343-
!(useFastRemove && canUseFastRemove),
344-
)
343+
if (oldKeyIndexMap.size) {
344+
for (const leftoverIndex of oldKeyIndexMap.values()) {
345+
unmount(
346+
oldBlocks[leftoverIndex],
347+
!(useFastRemove && canUseFastRemove),
348+
)
349+
}
345350
}
346351
if (useFastRemove && canUseFastRemove) {
347352
parent!.textContent = ''
348353
parent!.appendChild(parentAnchor)
349354
}
350355

351-
// Decide which reused blocks may stay in place: the longest
352-
// increasing subsequence of old indices is already in relative order,
353-
// so only the blocks outside it need a DOM move.
354-
//
355-
// Two planners, picked by how dense the change is across the range it
356-
// touches:
357-
//
358-
// - dense (the changed indices cover at least half their own span):
359-
// pull the in-place matches inside that span into the plan too and
360-
// run an unbounded LIS, which is move-count minimal like vdom's.
361-
// `span <= 2 * queuedLength` here, so this stays O(queued).
362-
// - sparse (a couple of rows moved across an otherwise untouched
363-
// list, e.g. a far swap): keep the in-place matches pinned, which
364-
// costs nothing but bounds each segment's LIS by its stationary
365-
// neighbours. Not minimal in general, but it never walks the
366-
// untouched majority.
367356
let sequence: number[] | undefined
368-
let allKept = false
369-
if (mountCounter !== queuedLength) {
370-
const span = queuedIndices[queuedLength - 1] - queuedIndices[0] + 1
371-
if (stationaryIndices && queuedLength * 2 >= span) {
372-
// dense: merge the stationaries inside the span into the plan,
373-
// keeping both parallel arrays in ascending index order
374-
const firstIndex = queuedIndices[0]
375-
const lastIndex = queuedIndices[queuedLength - 1]
376-
for (let i = 0; i < stationaryIndices.length; i++) {
377-
const index = stationaryIndices[i]
378-
if (index > firstIndex && index < lastIndex) {
379-
queuedIndices.push(index)
380-
// a prefix stationary sits at the same index in both lists
381-
sources.push(index + 1)
382-
}
383-
}
384-
if (queuedIndices.length !== queuedLength) {
385-
sortQueueByIndex(queuedIndices, sources)
386-
queuedLength = queuedIndices.length
387-
}
388-
}
389-
390-
let eligible = 0
391-
// if the eligible old positions already ascend, they are all in
392-
// relative order and every one of them stays put — no LIS needed
393-
let moved = false
394-
let maxSource = 0
395-
if (queuedLength * 2 >= span) {
396-
// dense: no bounds, every reuse competes for the LIS
397-
for (let q = 0; q < queuedLength; q++) {
398-
const value = sources[q]
399-
if (value !== 0) {
400-
eligible++
401-
if (value < maxSource) moved = true
402-
else maxSource = value
403-
}
404-
}
405-
} else {
406-
// sparse: in-place matches partition the queued indices into
407-
// segments of consecutive positions, and bound each segment's
408-
// eligible old indices. Because those bounds never overlap
409-
// between segments, one whole-array LIS still yields the same
410-
// answer as running it per segment.
411-
let seg = 0
412-
while (seg < queuedLength) {
413-
let segEnd = seg
414-
while (
415-
segEnd + 1 < queuedLength &&
416-
queuedIndices[segEnd + 1] === queuedIndices[segEnd] + 1
417-
) {
418-
segEnd++
419-
}
420-
// prefix stationaries sit at their own index in both lists; the
421-
// first suffix stationary (index e3) sits at old index e2. With
422-
// no bounding stationary the bounds are -1 / e2 == oldLength.
423-
const lowerBound = queuedIndices[seg] - 1
424-
const nextIndex = queuedIndices[segEnd] + 1
425-
const upperBound = nextIndex < e3 ? nextIndex : e2
426-
for (let q = seg; q <= segEnd; q++) {
427-
const value = sources[q]
428-
const oldIndex = value - 1
429-
if (
430-
oldIndex < 0 ||
431-
oldIndex <= lowerBound ||
432-
oldIndex >= upperBound
433-
) {
434-
sources[q] = 0
435-
} else {
436-
eligible++
437-
if (value < maxSource) moved = true
438-
else maxSource = value
439-
}
440-
}
441-
seg = segEnd + 1
442-
}
443-
}
444-
445-
if (eligible && moved) sequence = getSequence(sources)
446-
else if (eligible) allKept = true
357+
const hasReuse = mountCounter !== queuedLength
358+
if (hasReuse) {
359+
sequence = planMoves(queuedIndices, sources, e2, e3)
360+
// the dense planner expands both arrays in place
361+
queuedLength = queuedIndices.length
447362
}
363+
// reuses exist and none of them moved: every one of them stays put
364+
const allKept = hasReuse && sequence === undefined
448365

449366
// apply back-to-front so every block can anchor on the finalized
450367
// block after it (kept blocks count as finalized: relative order
@@ -464,6 +381,10 @@ export const createFor = (
464381
// a leading entry that was never selected; skip that marker
465382
isKept = sources[q] !== 0
466383
}
384+
// `scanFrom`/`cachedAnchor` stay where they are: the next block
385+
// that does move rescans the wider range, and each position is
386+
// still visited at most once.
387+
if (isKept) continue
467388

468389
// A block that renders nothing has no first node, so look past it
469390
// for the next attached one.
@@ -504,20 +425,22 @@ export const createFor = (
504425
scanFrom = index + 1
505426
cachedAnchor = anchorNode
506427

507-
if (isKept) continue
508-
if (newBlocks[index] !== undefined) {
509-
insertForBlock(newBlocks[index], anchorNode)
428+
const block = newBlocks[index]
429+
if (block !== undefined) {
430+
insertForBlock(block, anchorNode)
510431
} else {
511432
mount(source, index, anchorNode)
512433
}
513434
}
514435
}
515436
}
516437

517-
frag.nodes = [(oldBlocks = newBlocks)]
518-
if (parentAnchor) frag.nodes.push(parentAnchor)
438+
oldBlocks = newBlocks
439+
frag.nodes = parentAnchor ? [newBlocks, parentAnchor] : [newBlocks]
519440

520-
if (wasMounted && frag.onUpdated) frag.onUpdated.forEach(m => m())
441+
if (wasMounted && frag.onUpdated) {
442+
for (const fn of frag.onUpdated) fn()
443+
}
521444
setActiveSub(prevSub)
522445
}
523446

@@ -627,9 +550,7 @@ export const createFor = (
627550
try {
628551
if (emptyLocalRange && newLength) {
629552
reuseBoundaryClose(hydrationStart)
630-
for (let i = 0; i < newLength; i++) {
631-
mount(source, i, parentAnchor)
632-
}
553+
mountAll(source, newLength)
633554
setCurrentHydrationNode(parentAnchor)
634555
} else {
635556
for (let i = 0; i < newLength; i++) {
@@ -877,22 +798,112 @@ export function createSelector(source: () => any): ForSelector {
877798
return register
878799
}
879800

880-
// Restores ascending index order after the dense planner appended the
881-
// stationaries; both arrays move together. The appended tail is itself sorted
882-
// and usually short, so insertion sort runs near-linearly here.
883-
function sortQueueByIndex(indices: number[], oldIndices: number[]): void {
884-
for (let i = 1; i < indices.length; i++) {
885-
const index = indices[i]
886-
const oldIndex = oldIndices[i]
887-
let j = i - 1
888-
while (j >= 0 && indices[j] > index) {
889-
indices[j + 1] = indices[j]
890-
oldIndices[j + 1] = oldIndices[j]
891-
j--
801+
/**
802+
* Decides which reused blocks may stay where they are: the longest increasing
803+
* subsequence of their old indices is already in relative order, so only the
804+
* blocks outside it need a DOM move. Returns that subsequence as indices into
805+
* `sources`, or `undefined` when no reused block has to move at all.
806+
*
807+
* Two planners, picked by how dense the change is across the range it touches:
808+
*
809+
* - dense (the queued indices cover at least half of the range they span):
810+
* pull the in-place matches in that range into the plan too and run an
811+
* unbounded LIS, which is move-count minimal like vdom's. The range is at
812+
* most `2 * queuedIndices.length` wide here, so this stays O(queued).
813+
* - sparse (a couple of rows moved across an otherwise untouched list, e.g. a
814+
* far swap): keep the in-place matches pinned, which costs nothing but
815+
* bounds each segment's LIS by its stationary neighbours. Not minimal in
816+
* general, but it never walks the untouched majority.
817+
*
818+
* Both arrays are expanded in place on the dense path.
819+
*/
820+
function planMoves(
821+
queuedIndices: number[],
822+
sources: number[],
823+
e2: number,
824+
e3: number,
825+
): number[] | undefined {
826+
let queuedLength = queuedIndices.length
827+
// The dense range runs from the first queued index to the start of the
828+
// synchronized suffix: an in-place match on either side of a queued block
829+
// still constrains where it may land, so the plan has to cover them all,
830+
// not just the ones between the first and last queued index.
831+
const firstIndex = queuedIndices[0]
832+
const denseLength = e3 - firstIndex
833+
const isDense = queuedLength * 2 >= denseLength
834+
835+
// if the eligible old positions already ascend, they are all in relative
836+
// order and every one of them stays put - no LIS needed
837+
let moved = false
838+
let maxSource = 0
839+
840+
if (isDense) {
841+
// Expand in place, back to front so the tail writes never clobber entries
842+
// still to be read. Every position in the range that is not queued is a
843+
// same-index in-place match, so it needs no lookup.
844+
let read = queuedLength - 1
845+
queuedIndices.length = sources.length = denseLength
846+
for (
847+
let write = denseLength - 1, index = e3 - 1;
848+
write >= 0;
849+
write--, index--
850+
) {
851+
if (read >= 0 && queuedIndices[read] === index) {
852+
sources[write] = sources[read--]
853+
} else {
854+
sources[write] = index + 1
855+
}
856+
queuedIndices[write] = index
857+
}
858+
queuedLength = denseLength
859+
860+
// no bounds: every reuse competes for the LIS
861+
for (let q = 0; q < queuedLength; q++) {
862+
const value = sources[q]
863+
if (value !== 0) {
864+
if (value < maxSource) moved = true
865+
else maxSource = value
866+
}
867+
}
868+
} else {
869+
// in-place matches partition the queued indices into segments of
870+
// consecutive positions, and bound each segment's eligible old indices.
871+
// Because those bounds never overlap between segments, one whole-array
872+
// LIS still yields the same answer as running it per segment.
873+
let seg = 0
874+
while (seg < queuedLength) {
875+
let segEnd = seg
876+
while (
877+
segEnd + 1 < queuedLength &&
878+
queuedIndices[segEnd + 1] === queuedIndices[segEnd] + 1
879+
) {
880+
segEnd++
881+
}
882+
// prefix stationaries sit at their own index in both lists; the first
883+
// suffix stationary (index e3) sits at old index e2. With no bounding
884+
// stationary the bounds are -1 / e2 == oldLength.
885+
const lowerBound = queuedIndices[seg] - 1
886+
const nextIndex = queuedIndices[segEnd] + 1
887+
const upperBound = nextIndex < e3 ? nextIndex : e2
888+
for (let q = seg; q <= segEnd; q++) {
889+
const value = sources[q]
890+
// a mount has value 0, i.e. an old index of -1, which the lower
891+
// bound (>= -1) already rejects
892+
const oldIndex = value - 1
893+
if (oldIndex <= lowerBound || oldIndex >= upperBound) {
894+
sources[q] = 0
895+
} else {
896+
if (value < maxSource) moved = true
897+
else maxSource = value
898+
}
899+
}
900+
seg = segEnd + 1
892901
}
893-
indices[j + 1] = index
894-
oldIndices[j + 1] = oldIndex
895902
}
903+
904+
// `moved` can only be set by the second non-zero value onwards, so it
905+
// already implies there is something to keep
906+
return moved ? getSequence(sources) : undefined
896907
}
897908

898909
function stopBlockScopes(blocks: ForBlock[]): void {

0 commit comments

Comments
 (0)